crimsonland 0.1.0.dev11__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.
@@ -0,0 +1,663 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ import math
6
+ from pathlib import Path
7
+
8
+ import pyray as rl
9
+
10
+ from grim.assets import TextureLoader
11
+ from grim.config import CrimsonConfig
12
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
13
+
14
+ from ..persistence.highscores import (
15
+ NAME_MAX_EDIT,
16
+ TABLE_MAX,
17
+ HighScoreRecord,
18
+ rank_index,
19
+ read_highscore_table,
20
+ scores_path_for_mode,
21
+ upsert_highscore_record,
22
+ )
23
+ from ..quests.results import QuestFinalTime, QuestResultsBreakdownAnim, tick_quest_results_breakdown_anim
24
+ from .perk_menu import (
25
+ PerkMenuAssets,
26
+ UiButtonState,
27
+ button_draw,
28
+ button_update,
29
+ button_width,
30
+ cursor_draw,
31
+ draw_menu_panel,
32
+ draw_ui_text,
33
+ load_perk_menu_assets,
34
+ )
35
+
36
+
37
+ UI_BASE_WIDTH = 640.0
38
+ UI_BASE_HEIGHT = 480.0
39
+
40
+
41
+ def ui_scale(screen_w: float, screen_h: float) -> float:
42
+ # Classic UI-space: draw in backbuffer pixels.
43
+ return 1.0
44
+
45
+
46
+ def ui_origin(screen_w: float, screen_h: float, scale: float) -> tuple[float, float]:
47
+ return 0.0, 0.0
48
+
49
+
50
+ def _menu_widescreen_y_shift(layout_w: float) -> float:
51
+ # ui_menu_layout_init: pos_y += (screen_width / 640.0) * 150.0 - 150.0
52
+ return (layout_w / UI_BASE_WIDTH) * 150.0 - 150.0
53
+
54
+
55
+ # `quest_results_screen_update` base layout (Crimsonland classic UI panel).
56
+ # Values are derived from `ui_menu_assets_init` + `ui_menu_layout_init` and how
57
+ # the quest results screen composes `ui_menuPanel` geometry:
58
+ # panel_left = geom_x0 + pos_x + 180 + slide_x
59
+ # panel_top = geom_y0 + pos_y
60
+ #
61
+ # Where:
62
+ # - pos_x/pos_y are `ui_element_t` position fields set to (-45, 110)
63
+ # - geom_x0/geom_y0 are the first vertex coordinates of the `ui_menuPanel` geo,
64
+ # after `ui_menu_assets_init` transforms it into an 8-vertex 3-slice panel.
65
+ QUEST_RESULTS_PANEL_POS_X = -45.0
66
+ QUEST_RESULTS_PANEL_POS_Y = 110.0
67
+ QUEST_RESULTS_PANEL_GEOM_X0 = -63.0
68
+ QUEST_RESULTS_PANEL_GEOM_Y0 = -81.0
69
+ QUEST_RESULTS_PANEL_BASE_X = 180.0
70
+
71
+ QUEST_RESULTS_PANEL_W = 512.0
72
+ QUEST_RESULTS_PANEL_H = 379.0
73
+
74
+ TEXTURE_TOP_BANNER_W = 256.0
75
+ TEXTURE_TOP_BANNER_H = 64.0
76
+
77
+ INPUT_BOX_W = 166.0
78
+ INPUT_BOX_H = 18.0
79
+
80
+ PANEL_SLIDE_DURATION_MS = 250.0
81
+
82
+ COLOR_TEXT = rl.Color(255, 255, 255, 255)
83
+ COLOR_TEXT_MUTED = rl.Color(255, 255, 255, int(255 * 0.8))
84
+ COLOR_TEXT_SUBTLE = rl.Color(255, 255, 255, int(255 * 0.7))
85
+ COLOR_GREEN = rl.Color(25, 200, 25, 255)
86
+
87
+
88
+ def _poll_text_input(max_len: int, *, allow_space: bool = True) -> str:
89
+ out = ""
90
+ while True:
91
+ value = rl.get_char_pressed()
92
+ if value == 0:
93
+ break
94
+ if value < 0x20 or value > 0xFF:
95
+ continue
96
+ if not allow_space and value == 0x20:
97
+ continue
98
+ if len(out) >= max_len:
99
+ continue
100
+ out += chr(int(value))
101
+ return out
102
+
103
+
104
+ def _ease_out_cubic(t: float) -> float:
105
+ t = max(0.0, min(1.0, float(t)))
106
+ return 1.0 - (1.0 - t) ** 3
107
+
108
+
109
+ def _format_ordinal(value_1_based: int) -> str:
110
+ value = int(value_1_based)
111
+ if value % 100 in (11, 12, 13):
112
+ suffix = "th"
113
+ elif value % 10 == 1:
114
+ suffix = "st"
115
+ elif value % 10 == 2:
116
+ suffix = "nd"
117
+ elif value % 10 == 3:
118
+ suffix = "rd"
119
+ else:
120
+ suffix = "th"
121
+ return f"{value}{suffix}"
122
+
123
+
124
+ def _format_time_mm_ss(ms: int) -> str:
125
+ total_s = max(0, int(ms)) // 1000
126
+ minutes = total_s // 60
127
+ seconds = total_s % 60
128
+ return f"{minutes}:{seconds:02d}"
129
+
130
+
131
+ @dataclass(slots=True)
132
+ class QuestResultsAssets:
133
+ menu_panel: rl.Texture | None
134
+ text_well_done: rl.Texture | None
135
+ perk_menu_assets: PerkMenuAssets
136
+ missing: list[str]
137
+
138
+
139
+ def load_quest_results_assets(assets_root: Path) -> QuestResultsAssets:
140
+ perk_menu_assets = load_perk_menu_assets(assets_root)
141
+ loader = TextureLoader.from_assets_root(assets_root)
142
+ text_well_done = loader.get(
143
+ name="ui_textWellDone",
144
+ paq_rel="ui/ui_textWellDone.jaz",
145
+ fs_rel="ui/ui_textWellDone.png",
146
+ )
147
+ missing: list[str] = list(perk_menu_assets.missing)
148
+ missing.extend(loader.missing)
149
+ return QuestResultsAssets(
150
+ menu_panel=perk_menu_assets.menu_panel,
151
+ text_well_done=text_well_done,
152
+ perk_menu_assets=perk_menu_assets,
153
+ missing=missing,
154
+ )
155
+
156
+
157
+ @dataclass(slots=True)
158
+ class QuestResultsUi:
159
+ assets_root: Path
160
+ base_dir: Path
161
+ config: CrimsonConfig
162
+
163
+ assets: QuestResultsAssets | None = None
164
+ font: SmallFontData | None = None
165
+ missing_assets: list[str] = None # type: ignore[assignment]
166
+
167
+ phase: int = -1 # -1 init, 0 breakdown, 1 name entry (if qualifies), 2 results/buttons
168
+ rank: int = TABLE_MAX
169
+ highlight_rank: int | None = None
170
+
171
+ quest_level: str = ""
172
+ quest_title: str = ""
173
+ quest_stage_major: int = 0
174
+ quest_stage_minor: int = 0
175
+ unlock_weapon_name: str = ""
176
+ unlock_perk_name: str = ""
177
+
178
+ record: HighScoreRecord | None = None
179
+ breakdown: QuestFinalTime | None = None
180
+ _breakdown_anim: QuestResultsBreakdownAnim | None = None
181
+ _scores_path: Path | None = None
182
+
183
+ input_text: str = ""
184
+ input_caret: int = 0
185
+ _saved: bool = False
186
+
187
+ _intro_ms: float = 0.0
188
+ _panel_open_sfx_played: bool = False
189
+ _closing: bool = False
190
+ _close_action: str | None = None
191
+ _consume_enter: bool = False
192
+
193
+ _ok_button: UiButtonState = field(default_factory=lambda: UiButtonState("OK", force_wide=False))
194
+ _play_next_button: UiButtonState = field(default_factory=lambda: UiButtonState("Play Next", force_wide=True))
195
+ _play_again_button: UiButtonState = field(default_factory=lambda: UiButtonState("Play Again", force_wide=True))
196
+ _high_scores_button: UiButtonState = field(default_factory=lambda: UiButtonState("High scores", force_wide=True))
197
+ _main_menu_button: UiButtonState = field(default_factory=lambda: UiButtonState("Main Menu", force_wide=True))
198
+
199
+ def open(
200
+ self,
201
+ *,
202
+ record: HighScoreRecord,
203
+ breakdown: QuestFinalTime,
204
+ quest_level: str,
205
+ quest_title: str,
206
+ quest_stage_major: int,
207
+ quest_stage_minor: int,
208
+ unlock_weapon_name: str,
209
+ unlock_perk_name: str,
210
+ player_name_default: str,
211
+ ) -> None:
212
+ self.close()
213
+ self.missing_assets = []
214
+ try:
215
+ self.font = load_small_font(self.assets_root, self.missing_assets)
216
+ except Exception:
217
+ self.font = None
218
+ self.assets = load_quest_results_assets(self.assets_root)
219
+ if self.assets.missing:
220
+ self.missing_assets.extend(self.assets.missing)
221
+
222
+ self.phase = -1
223
+ self.rank = TABLE_MAX
224
+ self.highlight_rank = None
225
+ self.quest_level = str(quest_level or "")
226
+ self.quest_title = str(quest_title or "")
227
+ self.quest_stage_major = int(quest_stage_major)
228
+ self.quest_stage_minor = int(quest_stage_minor)
229
+ self.unlock_weapon_name = str(unlock_weapon_name or "")
230
+ self.unlock_perk_name = str(unlock_perk_name or "")
231
+ self.record = record.copy()
232
+ self.breakdown = breakdown
233
+ self._breakdown_anim = QuestResultsBreakdownAnim.start()
234
+ self._saved = False
235
+
236
+ # Native behavior: the final quest replaces "Play Next" with "Show End Note".
237
+ if int(self.quest_stage_major) == 5 and int(self.quest_stage_minor) == 10:
238
+ self._play_next_button.label = "Show End Note"
239
+ else:
240
+ self._play_next_button.label = "Play Next"
241
+
242
+ hardcore = bool(int(self.config.data.get("hardcore_flag", 0) or 0))
243
+ self._scores_path = scores_path_for_mode(
244
+ self.base_dir,
245
+ 3,
246
+ hardcore=hardcore,
247
+ quest_stage_major=int(self.quest_stage_major),
248
+ quest_stage_minor=int(self.quest_stage_minor),
249
+ )
250
+
251
+ try:
252
+ records = read_highscore_table(self._scores_path, game_mode_id=3)
253
+ self.rank = int(rank_index(records, self.record))
254
+ except Exception:
255
+ self.rank = TABLE_MAX
256
+
257
+ self.input_text = str(player_name_default or "")[:NAME_MAX_EDIT]
258
+ self.input_caret = len(self.input_text)
259
+
260
+ self._intro_ms = 0.0
261
+ self._panel_open_sfx_played = False
262
+ self._closing = False
263
+ self._close_action = None
264
+ self._consume_enter = True
265
+ self.phase = 0
266
+
267
+ def close(self) -> None:
268
+ if self.assets is not None:
269
+ self.assets = None
270
+ if self.font is not None:
271
+ rl.unload_texture(self.font.texture)
272
+ self.font = None
273
+
274
+ def _begin_close_transition(self, action: str) -> None:
275
+ if self._closing:
276
+ return
277
+ self._closing = True
278
+ self._close_action = action
279
+
280
+ def _text_width(self, text: str, scale: float) -> float:
281
+ if self.font is None:
282
+ return float(rl.measure_text(text, int(20 * scale)))
283
+ return float(measure_small_text_width(self.font, text, scale))
284
+
285
+ def _draw_small(self, text: str, x: float, y: float, scale: float, color: rl.Color) -> None:
286
+ if self.font is not None:
287
+ draw_small_text(self.font, text, x, y, scale, color)
288
+ else:
289
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
290
+
291
+ def _panel_layout(self, *, screen_w: float, scale: float) -> tuple[rl.Rectangle, float, float]:
292
+ t = self._intro_ms / PANEL_SLIDE_DURATION_MS if PANEL_SLIDE_DURATION_MS > 1e-6 else 1.0
293
+ eased = _ease_out_cubic(t)
294
+ panel_slide_x = -QUEST_RESULTS_PANEL_W * (1.0 - eased)
295
+
296
+ left = (QUEST_RESULTS_PANEL_GEOM_X0 + QUEST_RESULTS_PANEL_POS_X + QUEST_RESULTS_PANEL_BASE_X + panel_slide_x) * scale
297
+ layout_w = screen_w / scale if scale else screen_w
298
+ widescreen_shift_y = _menu_widescreen_y_shift(layout_w)
299
+ top = (QUEST_RESULTS_PANEL_GEOM_Y0 + QUEST_RESULTS_PANEL_POS_Y + widescreen_shift_y) * scale
300
+ panel = rl.Rectangle(float(left), float(top), QUEST_RESULTS_PANEL_W * scale, QUEST_RESULTS_PANEL_H * scale)
301
+ return panel, left, top
302
+
303
+ def update(
304
+ self,
305
+ dt: float,
306
+ *,
307
+ play_sfx: Callable[[str], None] | None = None,
308
+ rand: Callable[[], int] | None = None,
309
+ mouse: rl.Vector2 | None = None,
310
+ ) -> str | None:
311
+ dt_s = float(min(dt, 0.1))
312
+ dt_ms = dt_s * 1000.0
313
+ if mouse is None:
314
+ mouse = rl.get_mouse_position()
315
+ if rand is None:
316
+ def rand() -> int:
317
+ return 0
318
+
319
+ if self.assets is None or self.record is None or self.breakdown is None:
320
+ return None
321
+
322
+ if self._closing:
323
+ self._intro_ms = max(0.0, float(self._intro_ms) - dt_ms)
324
+ if self._intro_ms <= 1e-3 and self._close_action is not None:
325
+ action = self._close_action
326
+ self._close_action = None
327
+ self._closing = False
328
+ return action
329
+ return None
330
+
331
+ self._intro_ms = min(PANEL_SLIDE_DURATION_MS, self._intro_ms + dt_ms)
332
+ if (not self._panel_open_sfx_played) and play_sfx is not None and self._intro_ms >= PANEL_SLIDE_DURATION_MS - 1e-3:
333
+ play_sfx("sfx_ui_panelclick")
334
+ self._panel_open_sfx_played = True
335
+ if self._consume_enter:
336
+ self._consume_enter = False
337
+ rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER)
338
+
339
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
340
+ if play_sfx is not None:
341
+ play_sfx("sfx_ui_buttonclick")
342
+ self._begin_close_transition("main_menu")
343
+ return None
344
+
345
+ qualifies = int(self.rank) < TABLE_MAX
346
+
347
+ if self.phase == 0:
348
+ anim = self._breakdown_anim
349
+ if anim is None:
350
+ self._breakdown_anim = QuestResultsBreakdownAnim.start()
351
+ anim = self._breakdown_anim
352
+
353
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
354
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE) or click:
355
+ anim.set_final(self.breakdown)
356
+ self.phase = 1 if qualifies else 2
357
+ return None
358
+
359
+ clinks = tick_quest_results_breakdown_anim(
360
+ anim,
361
+ frame_dt_ms=int(dt_s * 1000.0),
362
+ target=self.breakdown,
363
+ )
364
+ if clinks > 0 and play_sfx is not None:
365
+ play_sfx("sfx_ui_clink_01")
366
+ if anim.done:
367
+ self.phase = 1 if qualifies else 2
368
+ return None
369
+
370
+ if self.phase == 1:
371
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
372
+ typed = _poll_text_input(NAME_MAX_EDIT - len(self.input_text), allow_space=True)
373
+ if typed:
374
+ self.input_text = (self.input_text[: self.input_caret] + typed + self.input_text[self.input_caret :])[:NAME_MAX_EDIT]
375
+ self.input_caret = min(len(self.input_text), self.input_caret + len(typed))
376
+ if play_sfx is not None:
377
+ play_sfx("sfx_ui_typeclick_01" if (int(rand()) & 1) == 0 else "sfx_ui_typeclick_02")
378
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE):
379
+ if self.input_caret > 0:
380
+ self.input_text = self.input_text[: self.input_caret - 1] + self.input_text[self.input_caret :]
381
+ self.input_caret -= 1
382
+ if play_sfx is not None:
383
+ play_sfx("sfx_ui_typeclick_01" if (int(rand()) & 1) == 0 else "sfx_ui_typeclick_02")
384
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
385
+ self.input_caret = max(0, self.input_caret - 1)
386
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
387
+ self.input_caret = min(len(self.input_text), self.input_caret + 1)
388
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
389
+ self.input_caret = 0
390
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
391
+ self.input_caret = len(self.input_text)
392
+
393
+ screen_w = float(rl.get_screen_width())
394
+ screen_h = float(rl.get_screen_height())
395
+ scale = ui_scale(screen_w, screen_h)
396
+ _panel, panel_left, panel_top = self._panel_layout(screen_w=screen_w, scale=scale)
397
+ anchor_x = panel_left + 40.0 * scale
398
+ input_y = panel_top + 150.0 * scale
399
+ ok_x = anchor_x + 170.0 * scale
400
+ ok_y = input_y - 8.0 * scale
401
+ ok_w = button_width(self.font, self._ok_button.label, scale=scale, force_wide=self._ok_button.force_wide)
402
+ ok_clicked = button_update(self._ok_button, x=ok_x, y=ok_y, width=ok_w, dt_ms=dt_ms, mouse=mouse, click=click)
403
+
404
+ if ok_clicked or rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
405
+ if self.input_text.strip():
406
+ if play_sfx is not None:
407
+ play_sfx("sfx_ui_typeenter")
408
+ if (not self._saved) and self._scores_path is not None:
409
+ candidate = self.record.copy()
410
+ candidate.set_name(self.input_text)
411
+ try:
412
+ _table, idx = upsert_highscore_record(self._scores_path, candidate)
413
+ self.highlight_rank = int(idx) if int(idx) < TABLE_MAX else None
414
+ if int(idx) < TABLE_MAX:
415
+ self.rank = int(idx)
416
+ except Exception:
417
+ self.highlight_rank = None
418
+ self._saved = True
419
+ self.phase = 2
420
+ return None
421
+ if play_sfx is not None:
422
+ play_sfx("sfx_shock_hit_01")
423
+ return None
424
+
425
+ if self.phase == 2:
426
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
427
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
428
+ if play_sfx is not None:
429
+ play_sfx("sfx_ui_buttonclick")
430
+ self._begin_close_transition("play_again")
431
+ return None
432
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_N):
433
+ if play_sfx is not None:
434
+ play_sfx("sfx_ui_buttonclick")
435
+ self._begin_close_transition("play_next")
436
+ return None
437
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_H):
438
+ if play_sfx is not None:
439
+ play_sfx("sfx_ui_buttonclick")
440
+ self._begin_close_transition("high_scores")
441
+ return None
442
+
443
+ screen_w = float(rl.get_screen_width())
444
+ screen_h = float(rl.get_screen_height())
445
+ scale = ui_scale(screen_w, screen_h)
446
+ _origin_x, _origin_y = ui_origin(screen_w, screen_h, scale)
447
+ _panel, left, top = self._panel_layout(screen_w=screen_w, scale=scale)
448
+ qualifies = int(self.rank) < TABLE_MAX
449
+ score_card_x = left + 70.0 * scale
450
+
451
+ var_c_12 = top + (96.0 if qualifies else 108.0) * scale
452
+ var_c_14 = var_c_12 + 84.0 * scale
453
+ if self.unlock_weapon_name:
454
+ var_c_14 += 30.0 * scale
455
+ if self.unlock_perk_name:
456
+ var_c_14 += 30.0 * scale
457
+
458
+ button_x = score_card_x + 20.0 * scale
459
+ button_y = var_c_14 + 6.0 * scale
460
+
461
+ play_next_w = button_width(self.font, self._play_next_button.label, scale=scale, force_wide=self._play_next_button.force_wide)
462
+ if button_update(self._play_next_button, x=button_x, y=button_y, width=play_next_w, dt_ms=dt_ms, mouse=mouse, click=click):
463
+ if play_sfx is not None:
464
+ play_sfx("sfx_ui_buttonclick")
465
+ self._begin_close_transition("play_next")
466
+ return None
467
+ button_y += 32.0 * scale
468
+
469
+ play_again_w = button_width(self.font, self._play_again_button.label, scale=scale, force_wide=self._play_again_button.force_wide)
470
+ if button_update(self._play_again_button, x=button_x, y=button_y, width=play_again_w, dt_ms=dt_ms, mouse=mouse, click=click):
471
+ if play_sfx is not None:
472
+ play_sfx("sfx_ui_buttonclick")
473
+ self._begin_close_transition("play_again")
474
+ return None
475
+ button_y += 32.0 * scale
476
+
477
+ high_scores_w = button_width(self.font, self._high_scores_button.label, scale=scale, force_wide=self._high_scores_button.force_wide)
478
+ if button_update(self._high_scores_button, x=button_x, y=button_y, width=high_scores_w, dt_ms=dt_ms, mouse=mouse, click=click):
479
+ if play_sfx is not None:
480
+ play_sfx("sfx_ui_buttonclick")
481
+ self._begin_close_transition("high_scores")
482
+ return None
483
+ button_y += 32.0 * scale
484
+
485
+ main_menu_w = button_width(self.font, self._main_menu_button.label, scale=scale, force_wide=self._main_menu_button.force_wide)
486
+ if button_update(self._main_menu_button, x=button_x, y=button_y, width=main_menu_w, dt_ms=dt_ms, mouse=mouse, click=click):
487
+ if play_sfx is not None:
488
+ play_sfx("sfx_ui_buttonclick")
489
+ self._begin_close_transition("main_menu")
490
+ return None
491
+ return None
492
+
493
+ return None
494
+
495
+ def draw(self, *, mouse: rl.Vector2 | None = None) -> None:
496
+ if self.assets is None or self.record is None or self.breakdown is None:
497
+ return
498
+ if mouse is None:
499
+ mouse = rl.get_mouse_position()
500
+
501
+ screen_w = float(rl.get_screen_width())
502
+ screen_h = float(rl.get_screen_height())
503
+ scale = ui_scale(screen_w, screen_h)
504
+ _origin_x, _origin_y = ui_origin(screen_w, screen_h, scale)
505
+ _ = _origin_x, _origin_y
506
+
507
+ panel, left, top = self._panel_layout(screen_w=screen_w, scale=scale)
508
+
509
+ if self.assets.menu_panel is not None:
510
+ draw_menu_panel(self.assets.menu_panel, dst=panel, tint=rl.WHITE)
511
+
512
+ banner_x = left + 22.0 * scale
513
+ banner_y = top + 36.0 * scale
514
+ if self.assets.text_well_done is not None:
515
+ src = rl.Rectangle(0.0, 0.0, float(self.assets.text_well_done.width), float(self.assets.text_well_done.height))
516
+ dst = rl.Rectangle(banner_x, banner_y, TEXTURE_TOP_BANNER_W * scale, TEXTURE_TOP_BANNER_H * scale)
517
+ rl.draw_texture_pro(self.assets.text_well_done, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
518
+
519
+ qualifies = int(self.rank) < TABLE_MAX
520
+
521
+ if self.phase == 0:
522
+ anchor_x = left + 40.0 * scale
523
+ label_x = anchor_x + 32.0 * scale
524
+ value_x = label_x + 132.0 * scale
525
+
526
+ anim = self._breakdown_anim
527
+ step = 4
528
+ highlight_alpha = 1.0
529
+ base_time_ms = int(self.breakdown.base_time_ms)
530
+ life_bonus_ms = int(self.breakdown.life_bonus_ms)
531
+ perk_bonus_ms = int(self.breakdown.unpicked_perk_bonus_ms)
532
+ final_time_ms = int(self.breakdown.final_time_ms)
533
+ if anim is not None and not anim.done:
534
+ step = int(anim.step)
535
+ highlight_alpha = float(anim.highlight_alpha())
536
+ base_time_ms = int(anim.base_time_ms)
537
+ life_bonus_ms = int(anim.life_bonus_ms)
538
+ perk_bonus_ms = int(anim.unpicked_perk_bonus_s) * 1000
539
+ final_time_ms = int(anim.final_time_ms)
540
+
541
+ def _row_color(idx: int, *, final: bool = False) -> rl.Color:
542
+ if anim is None or anim.done:
543
+ return COLOR_TEXT
544
+ alpha = 0.2
545
+ if idx < step:
546
+ alpha = 0.4
547
+ elif idx == step:
548
+ alpha = 1.0
549
+ if final:
550
+ alpha *= highlight_alpha
551
+ rgb = (255, 255, 255)
552
+ if idx == step:
553
+ rgb = (COLOR_GREEN.r, COLOR_GREEN.g, COLOR_GREEN.b)
554
+ return rl.Color(rgb[0], rgb[1], rgb[2], int(255 * max(0.0, min(1.0, alpha))))
555
+
556
+ y = top + 156.0 * scale
557
+ base_value = _format_time_mm_ss(base_time_ms)
558
+ life_value = _format_time_mm_ss(life_bonus_ms)
559
+ perk_value = _format_time_mm_ss(perk_bonus_ms)
560
+ final_value = _format_time_mm_ss(final_time_ms)
561
+
562
+ self._draw_small("Base Time:", label_x, y, 1.0 * scale, _row_color(0))
563
+ self._draw_small(base_value, value_x, y, 1.0 * scale, _row_color(0))
564
+ y += 20.0 * scale
565
+
566
+ self._draw_small("Life Bonus:", label_x, y, 1.0 * scale, _row_color(1))
567
+ self._draw_small(life_value, value_x, y, 1.0 * scale, _row_color(1))
568
+ y += 20.0 * scale
569
+
570
+ self._draw_small("Unpicked Perk Bonus:", label_x, y, 1.0 * scale, _row_color(2))
571
+ self._draw_small(perk_value, value_x, y, 1.0 * scale, _row_color(2))
572
+ y += 20.0 * scale
573
+
574
+ # Final time underline + row (matches the extra quad draw in native).
575
+ line_y = y + 1.0 * scale
576
+ line_color = rl.Color(255, 255, 255, _row_color(3, final=True).a)
577
+ rl.draw_rectangle(int(label_x - 4.0 * scale), int(line_y), int(168.0 * scale), int(1.0 * scale), line_color)
578
+
579
+ y += 8.0 * scale
580
+ self._draw_small("Final Time:", label_x, y, 1.0 * scale, _row_color(3, final=True))
581
+ self._draw_small(final_value, value_x, y, 1.0 * scale, _row_color(3, final=True))
582
+
583
+ elif self.phase == 1:
584
+ anchor_x = left + 40.0 * scale
585
+ text_y = top + 118.0 * scale
586
+ self._draw_small("State your name trooper!", anchor_x + 42.0 * scale, text_y, 1.0 * scale, COLOR_TEXT)
587
+
588
+ input_x = anchor_x
589
+ input_y = top + 150.0 * scale
590
+ rl.draw_rectangle_lines(int(input_x), int(input_y), int(INPUT_BOX_W * scale), int(INPUT_BOX_H * scale), rl.WHITE)
591
+ rl.draw_rectangle(
592
+ int(input_x + 1.0 * scale),
593
+ int(input_y + 1.0 * scale),
594
+ int((INPUT_BOX_W - 2.0) * scale),
595
+ int((INPUT_BOX_H - 2.0) * scale),
596
+ rl.Color(0, 0, 0, 255),
597
+ )
598
+ draw_ui_text(self.font, self.input_text, input_x + 4.0 * scale, input_y + 2.0 * scale, scale=1.0 * scale, color=COLOR_TEXT_MUTED)
599
+ caret_alpha = 1.0
600
+ if math.sin(float(rl.get_time()) * 4.0) > 0.0:
601
+ caret_alpha = 0.4
602
+ caret_color = rl.Color(255, 255, 255, int(255 * caret_alpha))
603
+ caret_x = input_x + 4.0 * scale + self._text_width(self.input_text[: self.input_caret], 1.0 * scale)
604
+ rl.draw_rectangle(int(caret_x), int(input_y + 2.0 * scale), int(1.0 * scale), int(14.0 * scale), caret_color)
605
+
606
+ ok_x = anchor_x + 170.0 * scale
607
+ ok_y = input_y - 8.0 * scale
608
+ ok_w = button_width(self.font, self._ok_button.label, scale=scale, force_wide=self._ok_button.force_wide)
609
+ button_draw(self.assets.perk_menu_assets, self.font, self._ok_button, x=ok_x, y=ok_y, width=ok_w, scale=scale)
610
+
611
+ else:
612
+ score_card_x = left + 70.0 * scale
613
+ var_c_12 = top + (96.0 if qualifies else 108.0) * scale
614
+ if (not qualifies) and self.font is not None:
615
+ self._draw_small(
616
+ "Score too low for top100.",
617
+ score_card_x + 8.0 * scale,
618
+ top + 102.0 * scale,
619
+ 1.0 * scale,
620
+ rl.Color(200, 200, 200, 255),
621
+ )
622
+
623
+ seconds = float(int(self.record.survival_elapsed_ms)) * 0.001
624
+ score_value = f"{seconds:.2f} secs"
625
+ xp_value = f"{int(self.record.score_xp)}"
626
+ rank_text = _format_ordinal(int(self.rank) + 1) if qualifies else "--"
627
+
628
+ col_label = rl.Color(230, 230, 230, 255)
629
+ col_value = rl.Color(230, 230, 255, 255)
630
+ card_y = var_c_12 + 16.0 * scale
631
+ self._draw_small("Score", score_card_x + 32.0 * scale, card_y, 1.0 * scale, col_label)
632
+ self._draw_small(score_value, score_card_x + 32.0 * scale, card_y + 15.0 * scale, 1.0 * scale, col_value)
633
+ self._draw_small(f"Rank: {rank_text}", score_card_x + 32.0 * scale, card_y + 30.0 * scale, 1.0 * scale, col_label)
634
+ self._draw_small("Experience", score_card_x + 140.0 * scale, card_y, 1.0 * scale, col_label)
635
+ self._draw_small(xp_value, score_card_x + 140.0 * scale, card_y + 15.0 * scale, 1.0 * scale, col_value)
636
+
637
+ # Unlock lines (their presence shifts the buttons down in native).
638
+ var_c_14 = var_c_12 + 84.0 * scale
639
+ if self.unlock_weapon_name:
640
+ self._draw_small("Weapon unlocked:", score_card_x, var_c_14 + 1.0 * scale, 1.0 * scale, COLOR_TEXT_SUBTLE)
641
+ self._draw_small(self.unlock_weapon_name, score_card_x, var_c_14 + 14.0 * scale, 1.0 * scale, COLOR_TEXT)
642
+ var_c_14 += 30.0 * scale
643
+ if self.unlock_perk_name:
644
+ self._draw_small("Perk unlocked:", score_card_x, var_c_14 + 1.0 * scale, 1.0 * scale, COLOR_TEXT_SUBTLE)
645
+ self._draw_small(self.unlock_perk_name, score_card_x, var_c_14 + 14.0 * scale, 1.0 * scale, COLOR_TEXT)
646
+ var_c_14 += 30.0 * scale
647
+
648
+ # Buttons
649
+ button_x = score_card_x + 20.0 * scale
650
+ button_y = var_c_14 + 6.0 * scale
651
+ play_next_w = button_width(self.font, self._play_next_button.label, scale=scale, force_wide=self._play_next_button.force_wide)
652
+ button_draw(self.assets.perk_menu_assets, self.font, self._play_next_button, x=button_x, y=button_y, width=play_next_w, scale=scale)
653
+ button_y += 32.0 * scale
654
+ play_again_w = button_width(self.font, self._play_again_button.label, scale=scale, force_wide=self._play_again_button.force_wide)
655
+ button_draw(self.assets.perk_menu_assets, self.font, self._play_again_button, x=button_x, y=button_y, width=play_again_w, scale=scale)
656
+ button_y += 32.0 * scale
657
+ high_scores_w = button_width(self.font, self._high_scores_button.label, scale=scale, force_wide=self._high_scores_button.force_wide)
658
+ button_draw(self.assets.perk_menu_assets, self.font, self._high_scores_button, x=button_x, y=button_y, width=high_scores_w, scale=scale)
659
+ button_y += 32.0 * scale
660
+ main_menu_w = button_width(self.font, self._main_menu_button.label, scale=scale, force_wide=self._main_menu_button.force_wide)
661
+ button_draw(self.assets.perk_menu_assets, self.font, self._main_menu_button, x=button_x, y=button_y, width=main_menu_w, scale=scale)
662
+
663
+ cursor_draw(self.assets.perk_menu_assets, mouse=mouse, scale=scale)