crimsonland 0.1.0.dev1__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 (138) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +153 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +377 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +663 -0
  34. crimson/gameplay.py +2450 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1039 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1338 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +56 -0
  67. crimson/sim/world_state.py +421 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +414 -0
  86. crimson/views/bonuses.py +201 -0
  87. crimson/views/camera_debug.py +359 -0
  88. crimson/views/camera_shake.py +229 -0
  89. crimson/views/corpse_stamp_debug.py +324 -0
  90. crimson/views/decals_debug.py +739 -0
  91. crimson/views/empty.py +19 -0
  92. crimson/views/fonts.py +114 -0
  93. crimson/views/game_over.py +117 -0
  94. crimson/views/ground.py +259 -0
  95. crimson/views/lighting_debug.py +1166 -0
  96. crimson/views/particles.py +293 -0
  97. crimson/views/perk_menu_debug.py +430 -0
  98. crimson/views/perks.py +398 -0
  99. crimson/views/player.py +433 -0
  100. crimson/views/player_sprite_debug.py +314 -0
  101. crimson/views/projectile_fx.py +608 -0
  102. crimson/views/projectile_render_debug.py +407 -0
  103. crimson/views/projectiles.py +221 -0
  104. crimson/views/quest_title_overlay.py +108 -0
  105. crimson/views/registry.py +34 -0
  106. crimson/views/rush.py +16 -0
  107. crimson/views/small_font_debug.py +204 -0
  108. crimson/views/spawn_plan.py +363 -0
  109. crimson/views/sprites.py +214 -0
  110. crimson/views/survival.py +15 -0
  111. crimson/views/terrain.py +132 -0
  112. crimson/views/ui.py +123 -0
  113. crimson/views/wicons.py +166 -0
  114. crimson/weapon_sfx.py +63 -0
  115. crimson/weapons.py +860 -0
  116. crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
  117. crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
  118. crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
  119. crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
  120. grim/__init__.py +20 -0
  121. grim/app.py +92 -0
  122. grim/assets.py +231 -0
  123. grim/audio.py +106 -0
  124. grim/config.py +294 -0
  125. grim/console.py +737 -0
  126. grim/fonts/__init__.py +7 -0
  127. grim/fonts/grim_mono.py +111 -0
  128. grim/fonts/small.py +120 -0
  129. grim/input.py +44 -0
  130. grim/jaz.py +103 -0
  131. grim/math.py +17 -0
  132. grim/music.py +403 -0
  133. grim/paq.py +76 -0
  134. grim/rand.py +37 -0
  135. grim/sfx.py +276 -0
  136. grim/sfx_map.py +103 -0
  137. grim/terrain_render.py +840 -0
  138. grim/view.py +16 -0
@@ -0,0 +1,660 @@
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.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
12
+
13
+ from ..persistence.highscores import (
14
+ NAME_MAX_EDIT,
15
+ TABLE_MAX,
16
+ HighScoreRecord,
17
+ rank_index,
18
+ read_highscore_table,
19
+ scores_path_for_config,
20
+ upsert_highscore_record,
21
+ )
22
+ from ..weapons import WEAPON_BY_ID
23
+ from .hud import HudAssets
24
+ from .perk_menu import (
25
+ PerkMenuAssets,
26
+ UiButtonState,
27
+ button_draw,
28
+ button_update,
29
+ button_width,
30
+ cursor_draw,
31
+ draw_ui_text,
32
+ load_perk_menu_assets,
33
+ )
34
+
35
+
36
+ UI_BASE_WIDTH = 640.0
37
+ UI_BASE_HEIGHT = 480.0
38
+
39
+
40
+ def ui_scale(screen_w: float, screen_h: float) -> float:
41
+ # Matches the classic UI-space helpers we use elsewhere: render in 640x480 pixel space.
42
+ return 1.0
43
+
44
+
45
+ def ui_origin(screen_w: float, screen_h: float, scale: float) -> tuple[float, float]:
46
+ return 0.0, 0.0
47
+
48
+
49
+ GAME_OVER_PANEL_X = -45.0
50
+ GAME_OVER_PANEL_Y = 210.0
51
+ GAME_OVER_PANEL_W = 512.0
52
+ GAME_OVER_PANEL_H = 256.0
53
+
54
+ GAME_OVER_PANEL_OFFSET_X = 20.0
55
+ GAME_OVER_PANEL_OFFSET_Y = -82.0
56
+
57
+ TEXTURE_TOP_BANNER_W = 256.0
58
+ TEXTURE_TOP_BANNER_H = 64.0
59
+
60
+ INPUT_BOX_W = 166.0 # `_DAT_0048259c = 0xa6` before `ui_text_input_update`
61
+ INPUT_BOX_H = 18.0
62
+
63
+ PANEL_SLIDE_DURATION_MS = 250.0
64
+
65
+ COLOR_TEXT = rl.Color(255, 255, 255, 255)
66
+ COLOR_TEXT_MUTED = rl.Color(255, 255, 255, int(255 * 0.8))
67
+ COLOR_SCORE_LABEL = rl.Color(230, 230, 230, 255)
68
+ COLOR_SCORE_VALUE = rl.Color(230, 230, 255, 255)
69
+
70
+
71
+ def _format_ordinal(value_1_based: int) -> str:
72
+ value = int(value_1_based)
73
+ if value % 100 in (11, 12, 13):
74
+ suffix = "th"
75
+ elif value % 10 == 1:
76
+ suffix = "st"
77
+ elif value % 10 == 2:
78
+ suffix = "nd"
79
+ elif value % 10 == 3:
80
+ suffix = "rd"
81
+ else:
82
+ suffix = "th"
83
+ return f"{value}{suffix}"
84
+
85
+
86
+ def _format_time_mm_ss(ms: int) -> str:
87
+ total_s = max(0, int(ms)) // 1000
88
+ minutes = total_s // 60
89
+ seconds = total_s % 60
90
+ return f"{minutes}:{seconds:02d}"
91
+
92
+
93
+ def _weapon_icon_src(texture: rl.Texture, weapon_id_native: int) -> rl.Rectangle | None:
94
+ weapon_id = int(weapon_id_native)
95
+ entry = WEAPON_BY_ID.get(int(weapon_id))
96
+ icon_index = entry.icon_index if entry is not None else None
97
+ if icon_index is None or icon_index < 0 or icon_index > 31:
98
+ return None
99
+ grid = 8
100
+ cell_w = float(texture.width) / grid
101
+ cell_h = float(texture.height) / grid
102
+ frame = int(icon_index) * 2
103
+ col = frame % grid
104
+ row = frame // grid
105
+ return rl.Rectangle(float(col * cell_w), float(row * cell_h), float(cell_w * 2), float(cell_h))
106
+
107
+
108
+ @dataclass(slots=True)
109
+ class GameOverAssets:
110
+ menu_panel: rl.Texture | None
111
+ text_reaper: rl.Texture | None
112
+ text_well_done: rl.Texture | None
113
+ perk_menu_assets: PerkMenuAssets
114
+ missing: list[str]
115
+
116
+
117
+ def load_game_over_assets(assets_root: Path) -> GameOverAssets:
118
+ perk_menu_assets = load_perk_menu_assets(assets_root)
119
+ loader = TextureLoader.from_assets_root(assets_root)
120
+ menu_panel = loader.get(name="ui_menuPanel", paq_rel="ui/ui_menuPanel.jaz", fs_rel="ui/ui_menuPanel.png")
121
+ text_reaper = loader.get(name="ui_textReaper", paq_rel="ui/ui_textReaper.jaz", fs_rel="ui/ui_textReaper.png")
122
+ text_well_done = loader.get(
123
+ name="ui_textWellDone",
124
+ paq_rel="ui/ui_textWellDone.jaz",
125
+ fs_rel="ui/ui_textWellDone.png",
126
+ )
127
+ missing: list[str] = list(perk_menu_assets.missing)
128
+ missing.extend(loader.missing)
129
+ return GameOverAssets(
130
+ menu_panel=menu_panel,
131
+ text_reaper=text_reaper,
132
+ text_well_done=text_well_done,
133
+ perk_menu_assets=perk_menu_assets,
134
+ missing=missing,
135
+ )
136
+
137
+
138
+ def _draw_texture_centered(tex: rl.Texture, x: float, y: float, w: float, h: float, alpha: float) -> None:
139
+ src = rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height))
140
+ dst = rl.Rectangle(float(x), float(y), float(w), float(h))
141
+ tint = rl.Color(255, 255, 255, int(255 * max(0.0, min(1.0, alpha))))
142
+ rl.draw_texture_pro(tex, src, dst, rl.Vector2(0.0, 0.0), 0.0, tint)
143
+
144
+
145
+ def _poll_text_input(max_len: int, *, allow_space: bool = True) -> str:
146
+ out = ""
147
+ while True:
148
+ value = rl.get_char_pressed()
149
+ if value == 0:
150
+ break
151
+ if value < 0x20 or value > 0xFF:
152
+ continue
153
+ if not allow_space and value == 0x20:
154
+ continue
155
+ if len(out) >= max_len:
156
+ continue
157
+ out += chr(int(value))
158
+ return out
159
+
160
+
161
+ def _ease_out_cubic(t: float) -> float:
162
+ t = max(0.0, min(1.0, float(t)))
163
+ return 1.0 - (1.0 - t) ** 3
164
+
165
+
166
+ @dataclass(slots=True)
167
+ class GameOverUi:
168
+ assets_root: Path
169
+ base_dir: Path
170
+
171
+ config: object # CrimsonConfig-like
172
+
173
+ assets: GameOverAssets | None = None
174
+ font: SmallFontData | None = None
175
+ missing_assets: list[str] = None # type: ignore[assignment]
176
+
177
+ input_text: str = ""
178
+ input_caret: int = 0
179
+ phase: int = -1 # -1 init, 0 name entry (if qualifies), 1 results/buttons
180
+ rank: int = TABLE_MAX
181
+ _candidate_record: HighScoreRecord | None = None
182
+ _saved: bool = False
183
+ _dt: float = 0.0
184
+
185
+ _hover_weapon: float = 0.0
186
+ _hover_time: float = 0.0
187
+ _hover_hit_ratio: float = 0.0
188
+ _intro_ms: float = 0.0
189
+ _panel_open_sfx_played: bool = False
190
+ _closing: bool = False
191
+ _close_action: str | None = None
192
+
193
+ # Buttons (rendered via existing ui_button implementation)
194
+ _ok_button: UiButtonState = field(default_factory=lambda: UiButtonState("OK", force_wide=False))
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
+ _consume_enter: bool = False
200
+
201
+ def open(self) -> None:
202
+ self.close()
203
+ self.missing_assets = []
204
+ try:
205
+ self.font = load_small_font(self.assets_root, self.missing_assets)
206
+ except Exception:
207
+ self.font = None
208
+ self.assets = load_game_over_assets(self.assets_root)
209
+ if self.assets.missing:
210
+ self.missing_assets.extend(self.assets.missing)
211
+ self.phase = -1
212
+ self.rank = TABLE_MAX
213
+ self._candidate_record = None
214
+ self._saved = False
215
+ self._dt = 0.0
216
+ self._hover_weapon = 0.0
217
+ self._hover_time = 0.0
218
+ self._hover_hit_ratio = 0.0
219
+ self._intro_ms = 0.0
220
+ self._panel_open_sfx_played = False
221
+ self._closing = False
222
+ self._close_action = None
223
+ self.input_text = ""
224
+ self.input_caret = 0
225
+ self._consume_enter = True
226
+
227
+ def close(self) -> None:
228
+ if self.assets is not None:
229
+ self.assets = None
230
+ if self.font is not None:
231
+ rl.unload_texture(self.font.texture)
232
+ self.font = None
233
+
234
+ def consume_enter(self) -> bool:
235
+ if self._consume_enter:
236
+ self._consume_enter = False
237
+ return True
238
+ return False
239
+
240
+ def _text_width(self, text: str, scale: float) -> float:
241
+ if self.font is None:
242
+ return float(rl.measure_text(text, int(20 * scale)))
243
+ return float(measure_small_text_width(self.font, text, scale))
244
+
245
+ def _draw_small(self, text: str, x: float, y: float, scale: float, color: rl.Color) -> None:
246
+ if self.font is not None:
247
+ draw_small_text(self.font, text, x, y, scale, color)
248
+ else:
249
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
250
+
251
+ def _panel_layout(self, *, scale: float) -> tuple[rl.Rectangle, float, float]:
252
+ # Keep consistent with the main menu panel offsets.
253
+ t = self._intro_ms / PANEL_SLIDE_DURATION_MS if PANEL_SLIDE_DURATION_MS > 1e-6 else 1.0
254
+ eased = _ease_out_cubic(t)
255
+ panel_slide_x = -GAME_OVER_PANEL_W * (1.0 - eased)
256
+
257
+ panel_x = (GAME_OVER_PANEL_X + panel_slide_x) * scale
258
+ panel_y = GAME_OVER_PANEL_Y * scale
259
+ origin_x = -(GAME_OVER_PANEL_OFFSET_X * scale)
260
+ origin_y = -(GAME_OVER_PANEL_OFFSET_Y * scale)
261
+ left = panel_x - origin_x
262
+ top = panel_y - origin_y
263
+ panel = rl.Rectangle(float(left), float(top), GAME_OVER_PANEL_W * scale, GAME_OVER_PANEL_H * scale)
264
+ return panel, left, top
265
+
266
+ def _begin_close_transition(self, action: str) -> None:
267
+ if self._closing:
268
+ return
269
+ self._closing = True
270
+ self._close_action = action
271
+
272
+ def update(
273
+ self,
274
+ dt: float,
275
+ *,
276
+ record: HighScoreRecord,
277
+ player_name_default: str,
278
+ play_sfx: Callable[[str], None] | None = None,
279
+ rand: Callable[[], int] | None = None,
280
+ mouse: rl.Vector2 | None = None,
281
+ ) -> str | None:
282
+ self._dt = float(min(dt, 0.1))
283
+ dt_ms = self._dt * 1000.0
284
+ if mouse is None:
285
+ mouse = rl.get_mouse_position()
286
+ if rand is None:
287
+ def rand() -> int:
288
+ return 0
289
+
290
+ if self.assets is None:
291
+ return None
292
+
293
+ if self._closing:
294
+ self._intro_ms = max(0.0, float(self._intro_ms) - dt_ms)
295
+ if self._intro_ms <= 1e-3 and self._close_action is not None:
296
+ action = self._close_action
297
+ self._close_action = None
298
+ self._closing = False
299
+ return action
300
+ return None
301
+
302
+ self._intro_ms = min(PANEL_SLIDE_DURATION_MS, self._intro_ms + dt_ms)
303
+ if (not self._panel_open_sfx_played) and play_sfx is not None and self._intro_ms >= PANEL_SLIDE_DURATION_MS - 1e-3:
304
+ play_sfx("sfx_ui_panelclick")
305
+ self._panel_open_sfx_played = True
306
+ if self._consume_enter:
307
+ self._consume_enter = False
308
+ rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER)
309
+ if self.phase == -1:
310
+ # If in the top 100, prompt for a name. Otherwise show score-too-low message and buttons.
311
+ game_mode_id = int(getattr(self.config, "data", {}).get("game_mode", 1))
312
+ candidate = record.copy()
313
+ candidate.game_mode_id = game_mode_id
314
+ self._candidate_record = candidate
315
+
316
+ path = scores_path_for_config(self.base_dir, self.config)
317
+ records = read_highscore_table(path, game_mode_id=game_mode_id)
318
+ idx = rank_index(records, candidate)
319
+ self.rank = int(idx)
320
+ if idx < TABLE_MAX:
321
+ self.phase = 0
322
+ self.input_text = player_name_default[:NAME_MAX_EDIT]
323
+ self.input_caret = len(self.input_text)
324
+ else:
325
+ self.phase = 1
326
+
327
+ # Basic text input behavior for the name-entry phase.
328
+ if self.phase == 0:
329
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
330
+ typed = _poll_text_input(NAME_MAX_EDIT - len(self.input_text), allow_space=True)
331
+ if typed:
332
+ self.input_text = (self.input_text[: self.input_caret] + typed + self.input_text[self.input_caret :])[:NAME_MAX_EDIT]
333
+ self.input_caret = min(len(self.input_text), self.input_caret + len(typed))
334
+ if play_sfx is not None:
335
+ play_sfx("sfx_ui_typeclick_01" if (int(rand()) & 1) == 0 else "sfx_ui_typeclick_02")
336
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE):
337
+ if self.input_caret > 0:
338
+ self.input_text = self.input_text[: self.input_caret - 1] + self.input_text[self.input_caret :]
339
+ self.input_caret -= 1
340
+ if play_sfx is not None:
341
+ play_sfx("sfx_ui_typeclick_01" if (int(rand()) & 1) == 0 else "sfx_ui_typeclick_02")
342
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
343
+ self.input_caret = max(0, self.input_caret - 1)
344
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
345
+ self.input_caret = min(len(self.input_text), self.input_caret + 1)
346
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
347
+ self.input_caret = 0
348
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
349
+ self.input_caret = len(self.input_text)
350
+
351
+ screen_w = float(rl.get_screen_width())
352
+ screen_h = float(rl.get_screen_height())
353
+ scale = ui_scale(screen_w, screen_h)
354
+ _panel, panel_left, panel_top = self._panel_layout(scale=scale)
355
+ banner_x = panel_left + (GAME_OVER_PANEL_W * scale - TEXTURE_TOP_BANNER_W * scale) * 0.5
356
+ banner_y = panel_top + 40.0 * scale
357
+ base_x = banner_x + 8.0 * scale
358
+ base_y = banner_y + 84.0 * scale
359
+ input_y = base_y + 40.0 * scale
360
+ ok_x = base_x + 170.0 * scale
361
+ ok_y = input_y - 8.0 * scale
362
+ ok_w = button_width(self.font, self._ok_button.label, scale=scale, force_wide=self._ok_button.force_wide)
363
+ ok_clicked = button_update(self._ok_button, x=ok_x, y=ok_y, width=ok_w, dt_ms=dt_ms, mouse=mouse, click=click)
364
+
365
+ if ok_clicked or rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
366
+ if self.input_text.strip():
367
+ if play_sfx is not None:
368
+ play_sfx("sfx_ui_typeenter")
369
+ candidate = (self._candidate_record or record).copy()
370
+ candidate.set_name(self.input_text)
371
+ path = scores_path_for_config(self.base_dir, self.config)
372
+ if not self._saved:
373
+ upsert_highscore_record(path, candidate)
374
+ self._saved = True
375
+ self.phase = 1
376
+ return None
377
+ if play_sfx is not None:
378
+ play_sfx("sfx_shock_hit_01")
379
+ else:
380
+ # Buttons phase: let the caller handle navigation; we just report actions.
381
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
382
+ screen_w = float(rl.get_screen_width())
383
+ screen_h = float(rl.get_screen_height())
384
+ scale = ui_scale(screen_w, screen_h)
385
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
386
+ _panel, left, top = self._panel_layout(scale=scale)
387
+ banner_x = left + (GAME_OVER_PANEL_W * scale - TEXTURE_TOP_BANNER_W * scale) * 0.5
388
+ banner_y = top + 40.0 * scale
389
+ score_y = banner_y + (64.0 if self.rank < TABLE_MAX else 62.0) * scale
390
+ x = banner_x + 52.0 * scale
391
+ y = score_y + 146.0 * scale
392
+ _ = origin_x, origin_y
393
+
394
+ play_again_w = button_width(self.font, self._play_again_button.label, scale=scale, force_wide=self._play_again_button.force_wide)
395
+ if button_update(self._play_again_button, x=x, y=y, width=play_again_w, dt_ms=dt_ms, mouse=mouse, click=click):
396
+ if play_sfx is not None:
397
+ play_sfx("sfx_ui_buttonclick")
398
+ self._begin_close_transition("play_again")
399
+ return None
400
+ y += 32.0 * scale
401
+
402
+ high_scores_w = button_width(self.font, self._high_scores_button.label, scale=scale, force_wide=self._high_scores_button.force_wide)
403
+ if button_update(self._high_scores_button, x=x, y=y, width=high_scores_w, dt_ms=dt_ms, mouse=mouse, click=click):
404
+ if play_sfx is not None:
405
+ play_sfx("sfx_ui_buttonclick")
406
+ self._begin_close_transition("high_scores")
407
+ return None
408
+ y += 32.0 * scale
409
+
410
+ main_menu_w = button_width(self.font, self._main_menu_button.label, scale=scale, force_wide=self._main_menu_button.force_wide)
411
+ if button_update(self._main_menu_button, x=x, y=y, width=main_menu_w, dt_ms=dt_ms, mouse=mouse, click=click):
412
+ if play_sfx is not None:
413
+ play_sfx("sfx_ui_buttonclick")
414
+ self._begin_close_transition("main_menu")
415
+ return None
416
+ return None
417
+
418
+ def _draw_score_card(
419
+ self,
420
+ *,
421
+ x: float,
422
+ y: float,
423
+ record: HighScoreRecord,
424
+ hud_assets: HudAssets | None,
425
+ alpha: float,
426
+ show_weapon_row: bool,
427
+ scale: float,
428
+ mouse: rl.Vector2,
429
+ ) -> None:
430
+ dt_hover = float(self._dt) * 2.0
431
+ label_color = rl.Color(COLOR_SCORE_LABEL.r, COLOR_SCORE_LABEL.g, COLOR_SCORE_LABEL.b, int(255 * alpha * 0.8))
432
+ value_color = rl.Color(COLOR_SCORE_VALUE.r, COLOR_SCORE_VALUE.g, COLOR_SCORE_VALUE.b, int(255 * alpha))
433
+ hint_color = rl.Color(COLOR_SCORE_LABEL.r, COLOR_SCORE_LABEL.g, COLOR_SCORE_LABEL.b, int(255 * alpha * 0.7))
434
+
435
+ base_x = x + 4.0 * scale
436
+ base_y = y
437
+
438
+ # Left column: Score + value + Rank.
439
+ score_label = "Score"
440
+ score_label_w = self._text_width(score_label, 1.0 * scale)
441
+ self._draw_small(score_label, base_x + 32.0 * scale - score_label_w * 0.5, base_y, 1.0 * scale, label_color)
442
+
443
+ if int(record.game_mode_id) in (2, 3):
444
+ seconds = float(int(record.survival_elapsed_ms)) * 0.001
445
+ score_value = f"{seconds:.2f} secs"
446
+ else:
447
+ score_value = f"{int(record.score_xp)}"
448
+ score_value_w = self._text_width(score_value, 1.0 * scale)
449
+ self._draw_small(score_value, base_x + 32.0 * scale - score_value_w * 0.5, base_y + 15.0 * scale, 1.0 * scale, value_color)
450
+
451
+ rank_value = _format_ordinal(int(self.rank) + 1)
452
+ rank_text = f"Rank: {rank_value}"
453
+ rank_w = self._text_width(rank_text, 1.0 * scale)
454
+ self._draw_small(rank_text, base_x + 32.0 * scale - rank_w * 0.5, base_y + 30.0 * scale, 1.0 * scale, label_color)
455
+
456
+ # Separator between columns (mirrors FUN_00441220 + offset adjustments).
457
+ sep_x = base_x + 80.0 * scale
458
+ rl.draw_line(int(sep_x), int(base_y), int(sep_x), int(base_y + 48.0 * scale), label_color)
459
+
460
+ # Right column: Game time + gauge, or Experience in quest mode.
461
+ col2_x = base_x + 96.0 * scale
462
+ if int(record.game_mode_id) == 3:
463
+ self._draw_small("Experience", col2_x, base_y, 1.0 * scale, label_color)
464
+ xp_value = f"{int(record.score_xp)}"
465
+ xp_w = self._text_width(xp_value, 1.0 * scale)
466
+ self._draw_small(xp_value, col2_x + 32.0 * scale - xp_w * 0.5, base_y + 15.0 * scale, 1.0 * scale, label_color)
467
+ self._hover_time = max(0.0, float(self._hover_time) - dt_hover)
468
+ else:
469
+ self._draw_small("Game time", col2_x + 6.0 * scale, base_y, 1.0 * scale, label_color)
470
+ time_rect = rl.Rectangle(col2_x + 8.0 * scale, base_y + 16.0 * scale, 64.0 * scale, 29.0 * scale)
471
+ hovering_time = rl.check_collision_point_rec(mouse, time_rect)
472
+ self._hover_time = float(max(0.0, min(1.0, self._hover_time + (dt_hover if hovering_time else -dt_hover))))
473
+
474
+ elapsed_ms = int(record.survival_elapsed_ms)
475
+ if hud_assets is not None and hud_assets.clock_table is not None:
476
+ src = rl.Rectangle(0.0, 0.0, float(hud_assets.clock_table.width), float(hud_assets.clock_table.height))
477
+ dst = rl.Rectangle(col2_x + 8.0 * scale, base_y + 14.0 * scale, 32.0 * scale, 32.0 * scale)
478
+ rl.draw_texture_pro(hud_assets.clock_table, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.Color(255, 255, 255, int(255 * alpha)))
479
+ if hud_assets is not None and hud_assets.clock_pointer is not None:
480
+ src = rl.Rectangle(0.0, 0.0, float(hud_assets.clock_pointer.width), float(hud_assets.clock_pointer.height))
481
+ # NOTE: Raylib's draw_texture_pro uses dst.x/y as the rotation origin position;
482
+ # offset by half-size so the 32x32 quad stays aligned with the table.
483
+ dst = rl.Rectangle(col2_x + 24.0 * scale, base_y + 30.0 * scale, 32.0 * scale, 32.0 * scale)
484
+ seconds = max(0, elapsed_ms // 1000)
485
+ rotation = float(seconds) * 6.0
486
+ origin = rl.Vector2(16.0 * scale, 16.0 * scale)
487
+ rl.draw_texture_pro(hud_assets.clock_pointer, src, dst, origin, rotation, rl.Color(255, 255, 255, int(255 * alpha)))
488
+
489
+ time_text = _format_time_mm_ss(elapsed_ms)
490
+ self._draw_small(time_text, col2_x + 40.0 * scale, base_y + 19.0 * scale, 1.0 * scale, label_color)
491
+
492
+ # Second row: weapon icon + frags + hit ratio (suppressed while entering the name).
493
+ row_y = base_y + 52.0 * scale
494
+ self._hover_weapon = float(max(0.0, min(1.0, self._hover_weapon)))
495
+ self._hover_hit_ratio = float(max(0.0, min(1.0, self._hover_hit_ratio)))
496
+ if show_weapon_row and hud_assets is not None and hud_assets.wicons is not None:
497
+ weapon_rect = rl.Rectangle(base_x, row_y, 64.0 * scale, 32.0 * scale)
498
+ hovering_weapon = rl.check_collision_point_rec(mouse, weapon_rect)
499
+ self._hover_weapon = float(max(0.0, min(1.0, self._hover_weapon + (dt_hover if hovering_weapon else -dt_hover))))
500
+
501
+ src = _weapon_icon_src(hud_assets.wicons, int(record.most_used_weapon_id))
502
+ if src is not None:
503
+ dst = rl.Rectangle(base_x, row_y, 64.0 * scale, 32.0 * scale)
504
+ rl.draw_texture_pro(hud_assets.wicons, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.Color(255, 255, 255, int(255 * alpha)))
505
+
506
+ weapon_id = int(record.most_used_weapon_id)
507
+ weapon_entry = WEAPON_BY_ID.get(int(weapon_id))
508
+ weapon_name = weapon_entry.name if weapon_entry is not None and weapon_entry.name else f"weapon_{weapon_id}"
509
+ name_w = self._text_width(weapon_name, 1.0 * scale)
510
+ name_x = base_x + max(0.0, (32.0 * scale - name_w * 0.5))
511
+ self._draw_small(weapon_name, name_x, row_y + 32.0 * scale, 1.0 * scale, hint_color)
512
+
513
+ frags_text = f"Frags: {int(record.creature_kill_count)}"
514
+ self._draw_small(frags_text, base_x + 110.0 * scale, row_y + 1.0 * scale, 1.0 * scale, label_color)
515
+
516
+ fired = max(0, int(record.shots_fired))
517
+ hit = max(0, int(record.shots_hit))
518
+ ratio = int((hit * 100) / fired) if fired > 0 else 0
519
+ hit_text = f"Hit %: {ratio}%"
520
+ self._draw_small(hit_text, base_x + 110.0 * scale, row_y + 15.0 * scale, 1.0 * scale, label_color)
521
+
522
+ hit_rect = rl.Rectangle(base_x + 110.0 * scale, row_y + 15.0 * scale, 64.0 * scale, 17.0 * scale)
523
+ hovering_hit = rl.check_collision_point_rec(mouse, hit_rect)
524
+ self._hover_hit_ratio = float(max(0.0, min(1.0, self._hover_hit_ratio + (dt_hover if hovering_hit else -dt_hover))))
525
+ tooltip_y = row_y + 48.0 * scale
526
+ else:
527
+ self._hover_weapon = max(0.0, float(self._hover_weapon) - dt_hover)
528
+ self._hover_hit_ratio = 0.0
529
+ tooltip_y = row_y
530
+
531
+ self._hover_weapon = float(max(0.0, min(1.0, self._hover_weapon)))
532
+ self._hover_time = float(max(0.0, min(1.0, self._hover_time)))
533
+ self._hover_hit_ratio = float(max(0.0, min(1.0, self._hover_hit_ratio)))
534
+
535
+ if self._hover_weapon > 0.5:
536
+ t = (self._hover_weapon - 0.5) * 2.0
537
+ col = rl.Color(label_color.r, label_color.g, label_color.b, int(255 * alpha * t))
538
+ self._draw_small("Most used weapon during the game", base_x - 20.0 * scale, tooltip_y, 1.0 * scale, col)
539
+ if self._hover_time > 0.5:
540
+ t = (self._hover_time - 0.5) * 2.0
541
+ col = rl.Color(label_color.r, label_color.g, label_color.b, int(255 * alpha * t))
542
+ self._draw_small("The time the game lasted", base_x + 12.0 * scale, tooltip_y, 1.0 * scale, col)
543
+ if self._hover_hit_ratio > 0.5:
544
+ t = (self._hover_hit_ratio - 0.5) * 2.0
545
+ col = rl.Color(label_color.r, label_color.g, label_color.b, int(255 * alpha * t))
546
+ self._draw_small("The % of shot bullets hit the target", base_x - 22.0 * scale, tooltip_y, 1.0 * scale, col)
547
+
548
+ def draw(
549
+ self,
550
+ *,
551
+ record: HighScoreRecord,
552
+ banner_kind: str,
553
+ hud_assets: HudAssets | None,
554
+ mouse: rl.Vector2 | None = None,
555
+ ) -> None:
556
+ if self.assets is None:
557
+ return
558
+ if mouse is None:
559
+ mouse = rl.get_mouse_position()
560
+
561
+ screen_w = float(rl.get_screen_width())
562
+ screen_h = float(rl.get_screen_height())
563
+ scale = ui_scale(screen_w, screen_h)
564
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
565
+ _ = origin_x, origin_y
566
+
567
+ panel, left, top = self._panel_layout(scale=scale)
568
+
569
+ # Panel background
570
+ if self.assets.menu_panel is not None:
571
+ src = rl.Rectangle(0.0, 0.0, float(self.assets.menu_panel.width), float(self.assets.menu_panel.height))
572
+ dst = rl.Rectangle(panel.x, panel.y, panel.width, panel.height)
573
+ rl.draw_texture_pro(self.assets.menu_panel, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
574
+
575
+ # Banner (Reaper / Well done)
576
+ banner = self.assets.text_reaper if banner_kind == "reaper" else self.assets.text_well_done
577
+ if banner is not None:
578
+ x = left + (panel.width - TEXTURE_TOP_BANNER_W * scale) * 0.5
579
+ y = top + 40.0 * scale
580
+ _draw_texture_centered(
581
+ banner,
582
+ x,
583
+ y,
584
+ TEXTURE_TOP_BANNER_W * scale,
585
+ TEXTURE_TOP_BANNER_H * scale,
586
+ 1.0,
587
+ )
588
+
589
+ banner_x = left + (panel.width - TEXTURE_TOP_BANNER_W * scale) * 0.5
590
+ banner_y = top + 40.0 * scale
591
+
592
+ if self.phase == 0:
593
+ base_x = banner_x + 8.0 * scale
594
+ base_y = banner_y + 84.0 * scale
595
+ self._draw_small("State your name, trooper!", base_x + 42.0 * scale, base_y, 1.0 * scale, COLOR_TEXT)
596
+
597
+ input_x = base_x
598
+ input_y = base_y + 40.0 * scale
599
+ rl.draw_rectangle_lines(int(input_x), int(input_y), int(INPUT_BOX_W * scale), int(INPUT_BOX_H * scale), rl.WHITE)
600
+ rl.draw_rectangle(int(input_x + 1.0 * scale), int(input_y + 1.0 * scale), int((INPUT_BOX_W - 2.0) * scale), int((INPUT_BOX_H - 2.0) * scale), rl.Color(0, 0, 0, 255))
601
+ 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)
602
+ caret_alpha = 1.0
603
+ if math.sin(float(rl.get_time()) * 4.0) > 0.0:
604
+ caret_alpha = 0.4
605
+ caret_color = rl.Color(255, 255, 255, int(255 * caret_alpha))
606
+ caret_x = input_x + 4.0 * scale + self._text_width(self.input_text[: self.input_caret], 1.0 * scale)
607
+ rl.draw_rectangle(int(caret_x), int(input_y + 2.0 * scale), int(1.0 * scale), int(14.0 * scale), caret_color)
608
+
609
+ ok_x = base_x + 170.0 * scale
610
+ ok_y = input_y - 8.0 * scale
611
+ ok_w = button_width(self.font, self._ok_button.label, scale=scale, force_wide=self._ok_button.force_wide)
612
+ button_draw(self.assets.perk_menu_assets, self.font, self._ok_button, x=ok_x, y=ok_y, width=ok_w, scale=scale)
613
+
614
+ score_x = base_x + 16.0 * scale
615
+ score_y = input_y + 60.0 * scale + 16.0 * scale
616
+ self._draw_score_card(
617
+ x=score_x,
618
+ y=score_y,
619
+ record=record,
620
+ hud_assets=hud_assets,
621
+ alpha=1.0,
622
+ show_weapon_row=False,
623
+ scale=scale,
624
+ mouse=mouse,
625
+ )
626
+ else:
627
+ score_card_x = banner_x + 30.0 * scale
628
+ text_y = banner_y + (64.0 if self.rank < TABLE_MAX else 62.0) * scale
629
+ if self.rank >= TABLE_MAX and banner_kind == "reaper":
630
+ self._draw_small("Score too low for top100.", banner_x + 38.0 * scale, text_y, 1.0 * scale, rl.Color(200, 200, 200, 255))
631
+ text_y += 6.0 * scale
632
+
633
+ self._draw_score_card(
634
+ x=score_card_x,
635
+ y=text_y + 16.0 * scale,
636
+ record=record,
637
+ hud_assets=hud_assets,
638
+ alpha=1.0,
639
+ show_weapon_row=True,
640
+ scale=scale,
641
+ mouse=mouse,
642
+ )
643
+
644
+ # Buttons phase rendering.
645
+ if self.phase == 1:
646
+ score_y = banner_y + (64.0 if self.rank < TABLE_MAX else 62.0) * scale
647
+ button_x = banner_x + 52.0 * scale
648
+ button_y = score_y + 146.0 * scale
649
+ play_again_w = button_width(self.font, self._play_again_button.label, scale=scale, force_wide=self._play_again_button.force_wide)
650
+ 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)
651
+ button_y += 32.0 * scale
652
+
653
+ high_scores_w = button_width(self.font, self._high_scores_button.label, scale=scale, force_wide=self._high_scores_button.force_wide)
654
+ 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)
655
+ button_y += 32.0 * scale
656
+
657
+ main_menu_w = button_width(self.font, self._main_menu_button.label, scale=scale, force_wide=self._main_menu_button.force_wide)
658
+ 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)
659
+
660
+ cursor_draw(self.assets.perk_menu_assets, mouse=mouse, scale=scale)