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
@@ -4,99 +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
29
53
 
54
+ _BACK_BUTTON_X = 394.0
55
+ _BACK_BUTTON_Y = 290.0
30
56
 
31
- class StatisticsMenuView(PanelMenuView):
32
- _PAGES = ("Summary", "Weapons", "Quests")
57
+ _PLAYTIME_X = 204.0
58
+ _PLAYTIME_Y = 334.0
59
+
60
+
61
+ class StatisticsMenuView:
62
+ """
63
+ Classic "Statistics" menu (state_id=4).
64
+
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
- rl.clear_background(rl.BLACK)
53
- if self._ground is not None:
54
- self._ground.draw(0.0, 0.0)
55
- _draw_screen_fade(self._state)
56
- assets = self._assets
57
- entry = self._entry
58
- if assets is None or entry is None:
59
- return
60
- self._draw_panel()
61
- self._draw_entry(entry)
62
- self._draw_sign()
63
- self._draw_stats_contents()
64
- _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
65
-
66
- def update(self, dt: float) -> None:
67
- super().update(dt)
68
- if self._closing:
69
- return
70
- entry = self._entry
71
- if entry is None or not self._entry_enabled(entry):
72
- return
73
-
74
- if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
75
- self._switch_page(-1)
76
- if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
77
- self._switch_page(1)
78
-
79
- font = self._ensure_small_font()
80
- layout = self._content_layout()
81
- rows = self._visible_rows(font, layout)
82
- max_scroll = max(0, len(self._active_page_lines()) - rows)
83
-
84
- wheel = int(rl.get_mouse_wheel_move())
85
- if wheel:
86
- self._scroll_index = max(0, min(max_scroll, int(self._scroll_index) - wheel))
87
-
88
- if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
89
- self._scroll_index = max(0, int(self._scroll_index) - 1)
90
- if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
91
- self._scroll_index = min(max_scroll, int(self._scroll_index) + 1)
92
- if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_UP):
93
- self._scroll_index = max(0, int(self._scroll_index) - rows)
94
- if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
95
- self._scroll_index = min(max_scroll, int(self._scroll_index) + rows)
96
- if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
97
- self._scroll_index = 0
98
- if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
99
- 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
100
128
 
101
129
  def _ensure_small_font(self) -> SmallFontData:
102
130
  if self._small_font is not None:
@@ -105,247 +133,176 @@ class StatisticsMenuView(PanelMenuView):
105
133
  self._small_font = load_small_font(self._state.assets_dir, missing_assets)
106
134
  return self._small_font
107
135
 
108
- def _content_layout(self) -> dict[str, float]:
109
- panel_scale, _local_shift = self._menu_item_scale(0)
110
- panel_w = MENU_PANEL_WIDTH * panel_scale
111
- _angle_rad, slide_x = MenuView._ui_element_anim(
112
- self,
113
- index=1,
114
- start_ms=PANEL_TIMELINE_START_MS,
115
- end_ms=PANEL_TIMELINE_END_MS,
116
- width=panel_w,
117
- )
118
- panel_x = self._panel_pos_x + slide_x
119
- panel_y = self._panel_pos_y + self._widescreen_y_shift
120
- origin_x = -(MENU_PANEL_OFFSET_X * panel_scale)
121
- origin_y = -(MENU_PANEL_OFFSET_Y * panel_scale)
122
- panel_left = panel_x - origin_x
123
- panel_top = panel_y - origin_y
124
- base_x = panel_left + 212.0 * panel_scale
125
- base_y = panel_top + 32.0 * panel_scale
126
- label_x = base_x + 8.0 * panel_scale
127
- return {
128
- "panel_left": panel_left,
129
- "panel_top": panel_top,
130
- "base_x": base_x,
131
- "base_y": base_y,
132
- "label_x": label_x,
133
- "scale": panel_scale,
134
- }
135
-
136
- def _switch_page(self, delta: int) -> None:
137
- 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"
138
152
  return
139
- count = len(self._page_lines)
140
- self._page_index = (int(self._page_index) + int(delta)) % count
141
- self._scroll_index = 0
142
-
143
- def _active_page_lines(self) -> list[str]:
144
- if not self._page_lines:
145
- return []
146
- idx = int(self._page_index)
147
- if idx < 0:
148
- idx = 0
149
- if idx >= len(self._page_lines):
150
- idx = len(self._page_lines) - 1
151
- return self._page_lines[idx]
152
-
153
- def _build_pages(self) -> list[list[str]]:
154
- return [
155
- self._build_summary_lines(),
156
- self._build_weapon_usage_lines(),
157
- self._build_quest_progress_lines(),
158
- ]
159
-
160
- def _build_summary_lines(self) -> list[str]:
161
- status = self._state.status
162
- mode_counts = {name: status.mode_play_count(name) for name, _offset in MODE_COUNT_ORDER}
163
- quest_counts = status.data.get("quest_play_counts", [])
164
- if isinstance(quest_counts, list):
165
- quest_total = int(sum(int(v) for v in quest_counts[:40]))
166
- else:
167
- quest_total = 0
168
-
169
- checksum_text = "unknown"
170
- try:
171
- from ...persistence.save_status import load_status
172
-
173
- blob = load_status(status.path)
174
- ok = "ok" if blob.checksum_valid else "BAD"
175
- checksum_text = f"0x{blob.checksum:08x} ({ok})"
176
- except Exception as exc:
177
- checksum_text = f"error: {type(exc).__name__}"
178
-
179
- playtime_ms = int(status.game_sequence_id)
180
- seconds = max(0, playtime_ms // 1000)
181
- minutes = seconds // 60
182
- hours = minutes // 60
183
- minutes %= 60
184
- seconds %= 60
185
-
186
- lines = [
187
- f"Played for: {hours}h {minutes:02d}m {seconds:02d}s",
188
- f"Quest unlock: {status.quest_unlock_index} (full {status.quest_unlock_index_full})",
189
- f"Quest plays (1-40): {quest_total}",
190
- f"Mode plays: surv {mode_counts['survival']} rush {mode_counts['rush']}",
191
- f" typo {mode_counts['typo']} other {mode_counts['other']}",
192
- f"Playtime ms: {int(status.game_sequence_id)}",
193
- f"Checksum: {checksum_text}",
194
- ]
195
-
196
- usage = status.data.get("weapon_usage_counts", [])
197
- top_weapons: list[tuple[int, int]] = []
198
- if isinstance(usage, list):
199
- for idx, count in enumerate(usage):
200
- count = int(count)
201
- if count > 0:
202
- top_weapons.append((idx, count))
203
- top_weapons.sort(key=lambda item: (-item[1], item[0]))
204
- top_weapons = top_weapons[:4]
205
-
206
- if top_weapons:
207
- lines.append("Top weapons:")
208
- for idx, count in top_weapons:
209
- weapon = WEAPON_BY_ID.get(idx)
210
- name = weapon.name if weapon is not None and weapon.name else f"weapon_{idx}"
211
- lines.append(f" {name}: {count}")
212
- else:
213
- lines.append("Top weapons: none")
214
-
215
- return lines
216
-
217
- def _build_weapon_usage_lines(self) -> list[str]:
218
- status = self._state.status
219
- usage = status.data.get("weapon_usage_counts", [])
220
- if not isinstance(usage, list):
221
- return ["Weapon usage: error (missing weapon_usage_counts)"]
222
-
223
- items: list[tuple[int, int, str]] = []
224
- for idx, count in enumerate(usage):
225
- weapon_id = int(idx)
226
- if weapon_id == WeaponId.NONE:
227
- continue
228
- count = int(count)
229
- weapon = WEAPON_BY_ID.get(weapon_id)
230
- name = weapon.name if weapon is not None and weapon.name else f"weapon_{weapon_id}"
231
- items.append((weapon_id, count, name))
232
-
233
- items.sort(key=lambda item: (-item[1], item[0]))
234
- total = sum(count for _weapon_id, count, _name in items)
235
- max_id_width = max(2, len(str(max((weapon_id for weapon_id, _count, _name in items), default=0))))
236
-
237
- lines = [
238
- f"Weapon uses (total {total}):",
239
- "",
240
- ]
241
- for weapon_id, count, name in items:
242
- lines.append(f"{weapon_id:>{max_id_width}} {count:>8} {name}")
243
- return lines
244
-
245
- def _build_quest_progress_lines(self) -> list[str]:
246
- status = self._state.status
247
- completed_total = 0
248
- played_total = 0
249
-
250
- lines = [
251
- "Quest progress (stages 1-4):",
252
- "",
253
- ]
254
- for global_index in range(40):
255
- stage = (global_index // 10) + 1
256
- row = global_index % 10
257
- level = f"{stage}.{row + 1}"
258
- title = "???"
259
- try:
260
- from ...quests import quest_by_level
261
-
262
- quest = quest_by_level(level)
263
- if quest is not None:
264
- title = quest.title
265
- except Exception:
266
- title = "???"
267
-
268
- count_index = global_index + 10
269
- games_idx = 1 + count_index
270
- completed_idx = 41 + count_index
271
- games = int(status.quest_play_count(games_idx))
272
- completed = int(status.quest_play_count(completed_idx))
273
-
274
- completed_total += completed
275
- played_total += games
276
- lines.append(f"{level:>4} {completed:>3}/{games:<3} {title}")
277
-
278
- lines.extend(
279
- [
280
- "",
281
- f"Completed: {completed_total}",
282
- f"Played: {played_total}",
283
- ]
284
- )
285
- return lines
286
153
 
287
- def _visible_rows(self, font: SmallFontData, layout: dict[str, float]) -> int:
288
- scale = float(layout["scale"])
289
- line_step = (float(font.cell_size) + 4.0) * scale
290
- line_y0 = float(layout["base_y"]) + 66.0 * scale
291
- panel_bottom = float(layout["panel_top"]) + (MENU_PANEL_HEIGHT * scale)
292
- available = max(0.0, panel_bottom - line_y0 - 8.0 * scale)
293
- 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)
294
208
 
295
- def _draw_stats_contents(self) -> None:
296
209
  assets = self._assets
297
- if assets is None:
210
+ if assets is None or assets.panel is None:
298
211
  return
299
- labels_tex = assets.labels
300
- layout = self._content_layout()
301
- base_x = layout["base_x"]
302
- base_y = layout["base_y"]
303
- label_x = layout["label_x"]
304
- scale = layout["scale"]
305
212
 
306
- font = self._ensure_small_font()
307
- text_scale = 1.0 * scale
308
- text_color = rl.Color(255, 255, 255, int(255 * 0.8))
309
-
310
- if labels_tex is not None:
311
- src = rl.Rectangle(
312
- 0.0,
313
- float(MENU_LABEL_ROW_STATISTICS) * MENU_LABEL_ROW_HEIGHT,
314
- MENU_LABEL_WIDTH,
315
- MENU_LABEL_ROW_HEIGHT,
316
- )
317
- dst = rl.Rectangle(
318
- base_x,
319
- base_y,
320
- MENU_LABEL_WIDTH * scale,
321
- MENU_LABEL_ROW_HEIGHT * scale,
322
- )
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)
323
224
  MenuView._draw_ui_quad(
324
- texture=labels_tex,
225
+ texture=label_tex,
325
226
  src=src,
326
- dst=dst,
227
+ dst=rl.Rectangle(panel_x0 + _TITLE_X * scale, panel_y0 + _TITLE_Y * scale, _TITLE_W * scale, _TITLE_H * scale),
327
228
  origin=rl.Vector2(0.0, 0.0),
328
229
  rotation_deg=0.0,
329
230
  tint=rl.WHITE,
330
231
  )
331
- else:
332
- rl.draw_text(self._title, int(base_x), int(base_y), int(24 * scale), rl.WHITE)
333
-
334
- tabs_y = base_y + 44.0 * scale
335
- x = label_x
336
- for idx, label in enumerate(self._PAGES):
337
- active = idx == int(self._page_index)
338
- color = rl.Color(255, 255, 255, 255 if active else int(255 * 0.55))
339
- draw_small_text(font, label, x, tabs_y, text_scale, color)
340
- x += (len(label) * font.cell_size + 18.0) * scale
341
-
342
- lines = self._active_page_lines()
343
- rows = self._visible_rows(font, layout)
344
- start = max(0, int(self._scroll_index))
345
- end = min(len(lines), start + rows)
346
-
347
- line_y = base_y + 66.0 * scale
348
- line_step = (font.cell_size + 4.0) * scale
349
- for line in lines[start:end]:
350
- draw_small_text(font, line, label_x, line_y, text_scale, text_color)
351
- 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
+ )