crimsonland 0.1.0.dev12__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.
@@ -4,97 +4,127 @@ from typing import TYPE_CHECKING
4
4
 
5
5
  import pyray as rl
6
6
 
7
- from grim.audio import play_music, stop_music
7
+ from grim.audio import play_music, play_sfx, stop_music, update_audio
8
8
  from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
9
9
 
10
+ from ...ui.menu_panel import draw_classic_menu_panel
11
+ from ...ui.perk_menu import UiButtonState, UiButtonTextureSet, button_draw, button_update, button_width
12
+ from ..assets import MenuAssets, _ensure_texture_cache, load_menu_assets
10
13
  from ..menu import (
11
14
  MENU_LABEL_ROW_HEIGHT,
12
15
  MENU_LABEL_ROW_STATISTICS,
13
- MENU_LABEL_WIDTH,
14
- MENU_PANEL_HEIGHT,
15
16
  MENU_PANEL_OFFSET_X,
16
17
  MENU_PANEL_OFFSET_Y,
17
18
  MENU_PANEL_WIDTH,
19
+ MENU_SCALE_SMALL_THRESHOLD,
20
+ MENU_SIGN_HEIGHT,
21
+ MENU_SIGN_OFFSET_X,
22
+ MENU_SIGN_OFFSET_Y,
23
+ MENU_SIGN_POS_X_PAD,
24
+ MENU_SIGN_POS_Y,
25
+ MENU_SIGN_POS_Y_SMALL,
26
+ MENU_SIGN_WIDTH,
27
+ UI_SHADOW_OFFSET,
18
28
  MenuView,
19
29
  _draw_menu_cursor,
30
+ ensure_menu_ground,
20
31
  )
21
32
  from ..transitions import _draw_screen_fade
22
- from .base import PANEL_TIMELINE_END_MS, PANEL_TIMELINE_START_MS, PanelMenuView
23
-
24
- from ...persistence.save_status import MODE_COUNT_ORDER
25
- from ...weapons import WEAPON_BY_ID, WeaponId
26
33
 
27
34
  if TYPE_CHECKING:
28
35
  from ...game import GameState
36
+ from grim.terrain_render import GroundRenderer
37
+
38
+
39
+ # Measured from ui_render_trace_oracle_1024x768.json (state_4:played for # hours # minutes, timeline=300).
40
+ STATISTICS_PANEL_POS_X = -89.0
41
+ STATISTICS_PANEL_POS_Y = 185.0
42
+ STATISTICS_PANEL_HEIGHT = 378.0
43
+
44
+ # Child layout inside the panel (relative to panel top-left).
45
+ _TITLE_X = 290.0
46
+ _TITLE_Y = 52.0
47
+ _TITLE_W = 128.0
48
+ _TITLE_H = 32.0
49
+
50
+ _BUTTON_X = 270.0
51
+ _BUTTON_Y0 = 104.0
52
+ _BUTTON_STEP_Y = 34.0
53
+
54
+ _BACK_BUTTON_X = 394.0
55
+ _BACK_BUTTON_Y = 290.0
56
+
57
+ _PLAYTIME_X = 204.0
58
+ _PLAYTIME_Y = 334.0
59
+
29
60
 
61
+ class StatisticsMenuView:
62
+ """
63
+ Classic "Statistics" menu (state_id=4).
30
64
 
31
- class StatisticsMenuView(PanelMenuView):
32
- _PAGES = ("Summary", "Weapons", "Quests")
65
+ This is a small hub panel with buttons for:
66
+ - High scores
67
+ - Weapons / Perks databases
68
+ - Credits
69
+ """
33
70
 
34
71
  def __init__(self, state: GameState) -> None:
35
- super().__init__(state, title="Statistics")
72
+ self._state = state
73
+ self._assets: MenuAssets | None = None
74
+ self._ground: GroundRenderer | None = None
36
75
  self._small_font: SmallFontData | None = None
37
- self._page_index = 0
38
- self._scroll_index = 0
39
- self._page_lines: list[list[str]] = []
76
+ self._button_textures: UiButtonTextureSet | None = None
77
+
78
+ self._cursor_pulse_time = 0.0
79
+ self._widescreen_y_shift = 0.0
80
+
81
+ self._action: str | None = None
82
+
83
+ self._btn_high_scores = UiButtonState("High scores", force_wide=True)
84
+ self._btn_weapons = UiButtonState("Weapons", force_wide=True)
85
+ self._btn_perks = UiButtonState("Perks", force_wide=True)
86
+ self._btn_credits = UiButtonState("Credits", force_wide=True)
87
+ self._btn_back = UiButtonState("Back", force_wide=False)
40
88
 
41
89
  def open(self) -> None:
42
- super().open()
43
- self._page_index = 0
44
- self._scroll_index = 0
45
- self._page_lines = self._build_pages()
90
+ layout_w = float(self._state.config.screen_width)
91
+ self._widescreen_y_shift = MenuView._menu_widescreen_y_shift(layout_w)
92
+ self._assets = load_menu_assets(self._state)
93
+ self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
94
+ self._small_font = None
95
+ self._cursor_pulse_time = 0.0
96
+ self._action = None
97
+
98
+ cache = _ensure_texture_cache(self._state)
99
+ button_md = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
100
+ button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
101
+ self._button_textures = UiButtonTextureSet(button_sm=button_sm, button_md=button_md)
102
+
103
+ self._btn_high_scores = UiButtonState("High scores", force_wide=True)
104
+ self._btn_weapons = UiButtonState("Weapons", force_wide=True)
105
+ self._btn_perks = UiButtonState("Perks", force_wide=True)
106
+ self._btn_credits = UiButtonState("Credits", force_wide=True)
107
+ self._btn_back = UiButtonState("Back", force_wide=False)
108
+
46
109
  if self._state.audio is not None:
47
110
  if self._state.audio.music.active_track != "shortie_monk":
48
111
  stop_music(self._state.audio)
49
112
  play_music(self._state.audio, "shortie_monk")
113
+ play_sfx(self._state.audio, "sfx_ui_panelclick", rng=self._state.rng)
50
114
 
51
- def draw(self) -> None:
52
- self._draw_background()
53
- _draw_screen_fade(self._state)
54
- assets = self._assets
55
- entry = self._entry
56
- if assets is None or entry is None:
57
- return
58
- self._draw_panel()
59
- self._draw_entry(entry)
60
- self._draw_sign()
61
- self._draw_stats_contents()
62
- _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
63
-
64
- def update(self, dt: float) -> None:
65
- super().update(dt)
66
- if self._closing:
67
- return
68
- entry = self._entry
69
- if entry is None or not self._entry_enabled(entry):
70
- return
71
-
72
- if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
73
- self._switch_page(-1)
74
- if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
75
- self._switch_page(1)
76
-
77
- font = self._ensure_small_font()
78
- layout = self._content_layout()
79
- rows = self._visible_rows(font, layout)
80
- max_scroll = max(0, len(self._active_page_lines()) - rows)
81
-
82
- wheel = int(rl.get_mouse_wheel_move())
83
- if wheel:
84
- self._scroll_index = max(0, min(max_scroll, int(self._scroll_index) - wheel))
85
-
86
- if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
87
- self._scroll_index = max(0, int(self._scroll_index) - 1)
88
- if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
89
- self._scroll_index = min(max_scroll, int(self._scroll_index) + 1)
90
- if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_UP):
91
- self._scroll_index = max(0, int(self._scroll_index) - rows)
92
- if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
93
- self._scroll_index = min(max_scroll, int(self._scroll_index) + rows)
94
- if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
95
- self._scroll_index = 0
96
- if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
97
- self._scroll_index = max_scroll
115
+ def close(self) -> None:
116
+ if self._small_font is not None:
117
+ rl.unload_texture(self._small_font.texture)
118
+ self._small_font = None
119
+ self._button_textures = None
120
+ self._assets = None
121
+ self._ground = None
122
+ self._action = None
123
+
124
+ def take_action(self) -> str | None:
125
+ action = self._action
126
+ self._action = None
127
+ return action
98
128
 
99
129
  def _ensure_small_font(self) -> SmallFontData:
100
130
  if self._small_font is not None:
@@ -103,247 +133,176 @@ class StatisticsMenuView(PanelMenuView):
103
133
  self._small_font = load_small_font(self._state.assets_dir, missing_assets)
104
134
  return self._small_font
105
135
 
106
- def _content_layout(self) -> dict[str, float]:
107
- panel_scale, _local_shift = self._menu_item_scale(0)
108
- panel_w = MENU_PANEL_WIDTH * panel_scale
109
- _angle_rad, slide_x = MenuView._ui_element_anim(
110
- self,
111
- index=1,
112
- start_ms=PANEL_TIMELINE_START_MS,
113
- end_ms=PANEL_TIMELINE_END_MS,
114
- width=panel_w,
115
- )
116
- panel_x = self._panel_pos_x + slide_x
117
- panel_y = self._panel_pos_y + self._widescreen_y_shift
118
- origin_x = -(MENU_PANEL_OFFSET_X * panel_scale)
119
- origin_y = -(MENU_PANEL_OFFSET_Y * panel_scale)
120
- panel_left = panel_x - origin_x
121
- panel_top = panel_y - origin_y
122
- base_x = panel_left + 212.0 * panel_scale
123
- base_y = panel_top + 32.0 * panel_scale
124
- label_x = base_x + 8.0 * panel_scale
125
- return {
126
- "panel_left": panel_left,
127
- "panel_top": panel_top,
128
- "base_x": base_x,
129
- "base_y": base_y,
130
- "label_x": label_x,
131
- "scale": panel_scale,
132
- }
133
-
134
- def _switch_page(self, delta: int) -> None:
135
- if not self._page_lines:
136
+ def _panel_top_left(self, *, scale: float) -> tuple[float, float]:
137
+ x0 = STATISTICS_PANEL_POS_X + MENU_PANEL_OFFSET_X * scale
138
+ y0 = STATISTICS_PANEL_POS_Y + self._widescreen_y_shift + MENU_PANEL_OFFSET_Y * scale
139
+ return float(x0), float(y0)
140
+
141
+ def update(self, dt: float) -> None:
142
+ if self._state.audio is not None:
143
+ update_audio(self._state.audio, dt)
144
+ if self._ground is not None:
145
+ self._ground.process_pending()
146
+ self._cursor_pulse_time += min(float(dt), 0.1) * 1.1
147
+
148
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
149
+ if self._state.audio is not None:
150
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
151
+ self._action = "back_to_menu"
136
152
  return
137
- count = len(self._page_lines)
138
- self._page_index = (int(self._page_index) + int(delta)) % count
139
- self._scroll_index = 0
140
-
141
- def _active_page_lines(self) -> list[str]:
142
- if not self._page_lines:
143
- return []
144
- idx = int(self._page_index)
145
- if idx < 0:
146
- idx = 0
147
- if idx >= len(self._page_lines):
148
- idx = len(self._page_lines) - 1
149
- return self._page_lines[idx]
150
-
151
- def _build_pages(self) -> list[list[str]]:
152
- return [
153
- self._build_summary_lines(),
154
- self._build_weapon_usage_lines(),
155
- self._build_quest_progress_lines(),
156
- ]
157
-
158
- def _build_summary_lines(self) -> list[str]:
159
- status = self._state.status
160
- mode_counts = {name: status.mode_play_count(name) for name, _offset in MODE_COUNT_ORDER}
161
- quest_counts = status.data.get("quest_play_counts", [])
162
- if isinstance(quest_counts, list):
163
- quest_total = int(sum(int(v) for v in quest_counts[:40]))
164
- else:
165
- quest_total = 0
166
-
167
- checksum_text = "unknown"
168
- try:
169
- from ...persistence.save_status import load_status
170
-
171
- blob = load_status(status.path)
172
- ok = "ok" if blob.checksum_valid else "BAD"
173
- checksum_text = f"0x{blob.checksum:08x} ({ok})"
174
- except Exception as exc:
175
- checksum_text = f"error: {type(exc).__name__}"
176
-
177
- playtime_ms = int(status.game_sequence_id)
178
- seconds = max(0, playtime_ms // 1000)
179
- minutes = seconds // 60
180
- hours = minutes // 60
181
- minutes %= 60
182
- seconds %= 60
183
-
184
- lines = [
185
- f"Played for: {hours}h {minutes:02d}m {seconds:02d}s",
186
- f"Quest unlock: {status.quest_unlock_index} (full {status.quest_unlock_index_full})",
187
- f"Quest plays (1-40): {quest_total}",
188
- f"Mode plays: surv {mode_counts['survival']} rush {mode_counts['rush']}",
189
- f" typo {mode_counts['typo']} other {mode_counts['other']}",
190
- f"Playtime ms: {int(status.game_sequence_id)}",
191
- f"Checksum: {checksum_text}",
192
- ]
193
-
194
- usage = status.data.get("weapon_usage_counts", [])
195
- top_weapons: list[tuple[int, int]] = []
196
- if isinstance(usage, list):
197
- for idx, count in enumerate(usage):
198
- count = int(count)
199
- if count > 0:
200
- top_weapons.append((idx, count))
201
- top_weapons.sort(key=lambda item: (-item[1], item[0]))
202
- top_weapons = top_weapons[:4]
203
-
204
- if top_weapons:
205
- lines.append("Top weapons:")
206
- for idx, count in top_weapons:
207
- weapon = WEAPON_BY_ID.get(idx)
208
- name = weapon.name if weapon is not None and weapon.name else f"weapon_{idx}"
209
- lines.append(f" {name}: {count}")
210
- else:
211
- lines.append("Top weapons: none")
212
-
213
- return lines
214
-
215
- def _build_weapon_usage_lines(self) -> list[str]:
216
- status = self._state.status
217
- usage = status.data.get("weapon_usage_counts", [])
218
- if not isinstance(usage, list):
219
- return ["Weapon usage: error (missing weapon_usage_counts)"]
220
-
221
- items: list[tuple[int, int, str]] = []
222
- for idx, count in enumerate(usage):
223
- weapon_id = int(idx)
224
- if weapon_id == WeaponId.NONE:
225
- continue
226
- count = int(count)
227
- weapon = WEAPON_BY_ID.get(weapon_id)
228
- name = weapon.name if weapon is not None and weapon.name else f"weapon_{weapon_id}"
229
- items.append((weapon_id, count, name))
230
-
231
- items.sort(key=lambda item: (-item[1], item[0]))
232
- total = sum(count for _weapon_id, count, _name in items)
233
- max_id_width = max(2, len(str(max((weapon_id for weapon_id, _count, _name in items), default=0))))
234
-
235
- lines = [
236
- f"Weapon uses (total {total}):",
237
- "",
238
- ]
239
- for weapon_id, count, name in items:
240
- lines.append(f"{weapon_id:>{max_id_width}} {count:>8} {name}")
241
- return lines
242
-
243
- def _build_quest_progress_lines(self) -> list[str]:
244
- status = self._state.status
245
- completed_total = 0
246
- played_total = 0
247
-
248
- lines = [
249
- "Quest progress (stages 1-4):",
250
- "",
251
- ]
252
- for global_index in range(40):
253
- stage = (global_index // 10) + 1
254
- row = global_index % 10
255
- level = f"{stage}.{row + 1}"
256
- title = "???"
257
- try:
258
- from ...quests import quest_by_level
259
-
260
- quest = quest_by_level(level)
261
- if quest is not None:
262
- title = quest.title
263
- except Exception:
264
- title = "???"
265
-
266
- count_index = global_index + 10
267
- games_idx = 1 + count_index
268
- completed_idx = 41 + count_index
269
- games = int(status.quest_play_count(games_idx))
270
- completed = int(status.quest_play_count(completed_idx))
271
-
272
- completed_total += completed
273
- played_total += games
274
- lines.append(f"{level:>4} {completed:>3}/{games:<3} {title}")
275
-
276
- lines.extend(
277
- [
278
- "",
279
- f"Completed: {completed_total}",
280
- f"Played: {played_total}",
281
- ]
282
- )
283
- return lines
284
153
 
285
- def _visible_rows(self, font: SmallFontData, layout: dict[str, float]) -> int:
286
- scale = float(layout["scale"])
287
- line_step = (float(font.cell_size) + 4.0) * scale
288
- line_y0 = float(layout["base_y"]) + 66.0 * scale
289
- panel_bottom = float(layout["panel_top"]) + (MENU_PANEL_HEIGHT * scale)
290
- available = max(0.0, panel_bottom - line_y0 - 8.0 * scale)
291
- return max(1, int(available // line_step))
154
+ textures = self._button_textures
155
+ if textures is None or (textures.button_md is None and textures.button_sm is None):
156
+ return
157
+
158
+ scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
159
+ panel_x0, panel_y0 = self._panel_top_left(scale=scale)
160
+
161
+ mouse = rl.get_mouse_position()
162
+ click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
163
+ dt_ms = min(float(dt), 0.1) * 1000.0
164
+
165
+ def _update_button(btn: UiButtonState, *, x: float, y: float) -> bool:
166
+ w = button_width(None, btn.label, scale=scale, force_wide=btn.force_wide)
167
+ return button_update(btn, x=x, y=y, width=w, dt_ms=dt_ms, mouse=mouse, click=click)
168
+
169
+ x = panel_x0 + _BUTTON_X * scale
170
+ y0 = panel_y0 + _BUTTON_Y0 * scale
171
+ if _update_button(self._btn_high_scores, x=x, y=y0 + _BUTTON_STEP_Y * 0.0 * scale):
172
+ if self._state.audio is not None:
173
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
174
+ self._action = "open_high_scores"
175
+ return
176
+ if _update_button(self._btn_weapons, x=x, y=y0 + _BUTTON_STEP_Y * 1.0 * scale):
177
+ if self._state.audio is not None:
178
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
179
+ self._action = "open_weapon_database"
180
+ return
181
+ if _update_button(self._btn_perks, x=x, y=y0 + _BUTTON_STEP_Y * 2.0 * scale):
182
+ if self._state.audio is not None:
183
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
184
+ self._action = "open_perk_database"
185
+ return
186
+ if _update_button(self._btn_credits, x=x, y=y0 + _BUTTON_STEP_Y * 3.0 * scale):
187
+ if self._state.audio is not None:
188
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
189
+ self._action = "open_credits"
190
+ return
191
+
192
+ back_x = panel_x0 + _BACK_BUTTON_X * scale
193
+ back_y = panel_y0 + _BACK_BUTTON_Y * scale
194
+ if _update_button(self._btn_back, x=back_x, y=back_y):
195
+ if self._state.audio is not None:
196
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
197
+ self._action = "back_to_menu"
198
+ return
199
+
200
+ def draw(self) -> None:
201
+ rl.clear_background(rl.BLACK)
202
+ pause_background = self._state.pause_background
203
+ if pause_background is not None:
204
+ pause_background.draw_pause_background()
205
+ elif self._ground is not None:
206
+ self._ground.draw(0.0, 0.0)
207
+ _draw_screen_fade(self._state)
292
208
 
293
- def _draw_stats_contents(self) -> None:
294
209
  assets = self._assets
295
- if assets is None:
210
+ if assets is None or assets.panel is None:
296
211
  return
297
- labels_tex = assets.labels
298
- layout = self._content_layout()
299
- base_x = layout["base_x"]
300
- base_y = layout["base_y"]
301
- label_x = layout["label_x"]
302
- scale = layout["scale"]
303
212
 
304
- font = self._ensure_small_font()
305
- text_scale = 1.0 * scale
306
- text_color = rl.Color(255, 255, 255, int(255 * 0.8))
307
-
308
- if labels_tex is not None:
309
- src = rl.Rectangle(
310
- 0.0,
311
- float(MENU_LABEL_ROW_STATISTICS) * MENU_LABEL_ROW_HEIGHT,
312
- MENU_LABEL_WIDTH,
313
- MENU_LABEL_ROW_HEIGHT,
314
- )
315
- dst = rl.Rectangle(
316
- base_x,
317
- base_y,
318
- MENU_LABEL_WIDTH * scale,
319
- MENU_LABEL_ROW_HEIGHT * scale,
320
- )
213
+ scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
214
+ panel_x0, panel_y0 = self._panel_top_left(scale=scale)
215
+ dst = rl.Rectangle(panel_x0, panel_y0, MENU_PANEL_WIDTH * scale, STATISTICS_PANEL_HEIGHT * scale)
216
+ fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
217
+ draw_classic_menu_panel(assets.panel, dst=dst, tint=rl.WHITE, shadow=fx_detail)
218
+
219
+ # Title: full-size row from ui_itemTexts.jaz (128x32).
220
+ if assets.labels is not None:
221
+ label_tex = assets.labels
222
+ row_h = float(MENU_LABEL_ROW_HEIGHT)
223
+ src = rl.Rectangle(0.0, float(MENU_LABEL_ROW_STATISTICS) * row_h, float(label_tex.width), row_h)
321
224
  MenuView._draw_ui_quad(
322
- texture=labels_tex,
225
+ texture=label_tex,
323
226
  src=src,
324
- dst=dst,
227
+ dst=rl.Rectangle(panel_x0 + _TITLE_X * scale, panel_y0 + _TITLE_Y * scale, _TITLE_W * scale, _TITLE_H * scale),
325
228
  origin=rl.Vector2(0.0, 0.0),
326
229
  rotation_deg=0.0,
327
230
  tint=rl.WHITE,
328
231
  )
329
- else:
330
- rl.draw_text(self._title, int(base_x), int(base_y), int(24 * scale), rl.WHITE)
331
-
332
- tabs_y = base_y + 44.0 * scale
333
- x = label_x
334
- for idx, label in enumerate(self._PAGES):
335
- active = idx == int(self._page_index)
336
- color = rl.Color(255, 255, 255, 255 if active else int(255 * 0.55))
337
- draw_small_text(font, label, x, tabs_y, text_scale, color)
338
- x += (len(label) * font.cell_size + 18.0) * scale
339
-
340
- lines = self._active_page_lines()
341
- rows = self._visible_rows(font, layout)
342
- start = max(0, int(self._scroll_index))
343
- end = min(len(lines), start + rows)
344
-
345
- line_y = base_y + 66.0 * scale
346
- line_step = (font.cell_size + 4.0) * scale
347
- for line in lines[start:end]:
348
- draw_small_text(font, line, label_x, line_y, text_scale, text_color)
349
- line_y += line_step
232
+
233
+ # "played for # hours # minutes"
234
+ font = self._ensure_small_font()
235
+ playtime_text = "played for 0 hours 0 minutes"
236
+ try:
237
+ # The classic menu shows a coarse playtime summary; our persisted value is ms.
238
+ ms = max(0, int(self._state.status.game_sequence_id))
239
+ minutes = (ms // 1000) // 60
240
+ hours = minutes // 60
241
+ minutes %= 60
242
+ playtime_text = f"played for {hours} hours {minutes} minutes"
243
+ except Exception:
244
+ playtime_text = "played for ? hours ? minutes"
245
+
246
+ draw_small_text(
247
+ font,
248
+ playtime_text,
249
+ panel_x0 + _PLAYTIME_X * scale,
250
+ panel_y0 + _PLAYTIME_Y * scale,
251
+ 1.0 * scale,
252
+ rl.Color(255, 255, 255, int(255 * 0.8)),
253
+ )
254
+
255
+ # Buttons.
256
+ textures = self._button_textures
257
+ if textures is not None and (textures.button_md is not None or textures.button_sm is not None):
258
+ btn_x = panel_x0 + _BUTTON_X * scale
259
+ btn_y0 = panel_y0 + _BUTTON_Y0 * scale
260
+ for i, btn in enumerate((self._btn_high_scores, self._btn_weapons, self._btn_perks, self._btn_credits)):
261
+ w = button_width(None, btn.label, scale=scale, force_wide=btn.force_wide)
262
+ button_draw(textures, font, btn, x=btn_x, y=btn_y0 + _BUTTON_STEP_Y * float(i) * scale, width=w, scale=scale)
263
+
264
+ back_w = button_width(None, self._btn_back.label, scale=scale, force_wide=self._btn_back.force_wide)
265
+ button_draw(
266
+ textures,
267
+ font,
268
+ self._btn_back,
269
+ x=panel_x0 + _BACK_BUTTON_X * scale,
270
+ y=panel_y0 + _BACK_BUTTON_Y * scale,
271
+ width=back_w,
272
+ scale=scale,
273
+ )
274
+
275
+ self._draw_sign(scale=scale)
276
+ _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
277
+
278
+ def _draw_sign(self, *, scale: float) -> None:
279
+ assets = self._assets
280
+ if assets is None or assets.sign is None:
281
+ return
282
+ sign = assets.sign
283
+ screen_w = float(self._state.config.screen_width)
284
+ sign_scale, shift_x = MenuView._sign_layout_scale(int(screen_w))
285
+ pos_x = screen_w + MENU_SIGN_POS_X_PAD
286
+ pos_y = MENU_SIGN_POS_Y if screen_w > MENU_SCALE_SMALL_THRESHOLD else MENU_SIGN_POS_Y_SMALL
287
+ sign_w = MENU_SIGN_WIDTH * sign_scale
288
+ sign_h = MENU_SIGN_HEIGHT * sign_scale
289
+ offset_x = MENU_SIGN_OFFSET_X * sign_scale + shift_x
290
+ offset_y = MENU_SIGN_OFFSET_Y * sign_scale
291
+ rotation_deg = 0.0
292
+ fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
293
+ if fx_detail:
294
+ MenuView._draw_ui_quad_shadow(
295
+ texture=sign,
296
+ src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
297
+ dst=rl.Rectangle(pos_x + UI_SHADOW_OFFSET, pos_y + UI_SHADOW_OFFSET, sign_w, sign_h),
298
+ origin=rl.Vector2(-offset_x, -offset_y),
299
+ rotation_deg=rotation_deg,
300
+ )
301
+ MenuView._draw_ui_quad(
302
+ texture=sign,
303
+ src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
304
+ dst=rl.Rectangle(pos_x, pos_y, sign_w, sign_h),
305
+ origin=rl.Vector2(-offset_x, -offset_y),
306
+ rotation_deg=rotation_deg,
307
+ tint=rl.WHITE,
308
+ )