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