crimsonland 0.1.0.dev5__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 (139) 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 +155 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +380 -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 +652 -0
  34. crimson/gameplay.py +2467 -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 +1133 -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 +1941 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +67 -0
  67. crimson/sim/world_state.py +422 -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 +404 -0
  86. crimson/views/audio_bootstrap.py +47 -0
  87. crimson/views/bonuses.py +201 -0
  88. crimson/views/camera_debug.py +359 -0
  89. crimson/views/camera_shake.py +229 -0
  90. crimson/views/corpse_stamp_debug.py +324 -0
  91. crimson/views/decals_debug.py +739 -0
  92. crimson/views/empty.py +19 -0
  93. crimson/views/fonts.py +114 -0
  94. crimson/views/game_over.py +117 -0
  95. crimson/views/ground.py +259 -0
  96. crimson/views/lighting_debug.py +1166 -0
  97. crimson/views/particles.py +293 -0
  98. crimson/views/perk_menu_debug.py +430 -0
  99. crimson/views/perks.py +398 -0
  100. crimson/views/player.py +434 -0
  101. crimson/views/player_sprite_debug.py +314 -0
  102. crimson/views/projectile_fx.py +609 -0
  103. crimson/views/projectile_render_debug.py +393 -0
  104. crimson/views/projectiles.py +221 -0
  105. crimson/views/quest_title_overlay.py +108 -0
  106. crimson/views/registry.py +34 -0
  107. crimson/views/rush.py +16 -0
  108. crimson/views/small_font_debug.py +204 -0
  109. crimson/views/spawn_plan.py +363 -0
  110. crimson/views/sprites.py +214 -0
  111. crimson/views/survival.py +15 -0
  112. crimson/views/terrain.py +132 -0
  113. crimson/views/ui.py +123 -0
  114. crimson/views/wicons.py +166 -0
  115. crimson/weapon_sfx.py +63 -0
  116. crimson/weapons.py +860 -0
  117. crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
  118. crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
  119. crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
  120. crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
  121. grim/__init__.py +20 -0
  122. grim/app.py +92 -0
  123. grim/assets.py +231 -0
  124. grim/audio.py +106 -0
  125. grim/config.py +294 -0
  126. grim/console.py +737 -0
  127. grim/fonts/__init__.py +7 -0
  128. grim/fonts/grim_mono.py +111 -0
  129. grim/fonts/small.py +120 -0
  130. grim/input.py +44 -0
  131. grim/jaz.py +103 -0
  132. grim/math.py +17 -0
  133. grim/music.py +403 -0
  134. grim/paq.py +76 -0
  135. grim/rand.py +37 -0
  136. grim/sfx.py +276 -0
  137. grim/sfx_map.py +103 -0
  138. grim/terrain_render.py +840 -0
  139. grim/view.py +16 -0
@@ -0,0 +1,700 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+ import math
6
+ import os
7
+
8
+ import pyray as rl
9
+
10
+ from grim.audio import play_music, play_sfx, stop_music, update_audio
11
+ from grim.terrain_render import GroundRenderer
12
+
13
+ from ..ui.cursor import draw_menu_cursor
14
+ from .assets import MenuAssets, _ensure_texture_cache, load_menu_assets
15
+ from .transitions import _draw_screen_fade
16
+
17
+ if TYPE_CHECKING:
18
+ from ..game import GameState
19
+
20
+
21
+ MENU_LABEL_WIDTH = 124.0
22
+ MENU_LABEL_HEIGHT = 30.0
23
+ MENU_LABEL_ROW_HEIGHT = 32.0
24
+ MENU_LABEL_ROW_PLAY_GAME = 1
25
+ MENU_LABEL_ROW_OPTIONS = 2
26
+ MENU_LABEL_ROW_STATISTICS = 3
27
+ MENU_LABEL_ROW_MODS = 4
28
+ MENU_LABEL_ROW_OTHER_GAMES = 5
29
+ MENU_LABEL_ROW_QUIT = 6
30
+ MENU_LABEL_ROW_BACK = 7
31
+ MENU_LABEL_BASE_X = -60.0
32
+ MENU_LABEL_BASE_Y = 210.0
33
+ MENU_LABEL_OFFSET_X = 270.0
34
+ MENU_LABEL_OFFSET_Y = -38.0
35
+ MENU_LABEL_STEP = 60.0
36
+ MENU_ITEM_OFFSET_X = -72.0
37
+ MENU_ITEM_OFFSET_Y = -60.0
38
+ MENU_PANEL_WIDTH = 512.0
39
+ MENU_PANEL_HEIGHT = 256.0
40
+ MENU_PANEL_OFFSET_X = 20.0
41
+ MENU_PANEL_OFFSET_Y = -82.0
42
+ MENU_PANEL_BASE_X = -45.0
43
+ MENU_PANEL_BASE_Y = 210.0
44
+ MENU_SCALE_SMALL_THRESHOLD = 640
45
+ MENU_SCALE_LARGE_MIN = 801
46
+ MENU_SCALE_LARGE_MAX = 1024
47
+ MENU_SCALE_SMALL = 0.8
48
+ MENU_SCALE_LARGE = 1.2
49
+ MENU_SCALE_SHIFT = 10.0
50
+
51
+ # ui_element_render (0x446c40): shadow pass uses offset (7, 7), tint 0x44444444, and
52
+ # blend factors (src=ZERO, dst=ONE_MINUS_SRC_ALPHA).
53
+ UI_SHADOW_OFFSET = 7.0
54
+ UI_SHADOW_TINT = rl.Color(0x44, 0x44, 0x44, 0x44)
55
+
56
+ MENU_SIGN_WIDTH = 573.44
57
+ MENU_SIGN_HEIGHT = 143.36
58
+ MENU_SIGN_OFFSET_X = -577.44
59
+ MENU_SIGN_OFFSET_Y = -62.0
60
+ MENU_SIGN_POS_Y = 70.0
61
+ MENU_SIGN_POS_Y_SMALL = 60.0
62
+ MENU_SIGN_POS_X_PAD = 4.0
63
+
64
+ # Measured in the shareware/demo attract loop trace:
65
+ # {"event":"demo_mode_start","dt_since_start_ms":23024,"game_state_id":0,"demo_mode_active":0,...}
66
+ MENU_DEMO_IDLE_START_MS = 23_000
67
+
68
+
69
+ def ensure_menu_ground(state: GameState, *, regenerate: bool = False) -> GroundRenderer | None:
70
+ cache = state.texture_cache
71
+ if cache is None:
72
+ return None
73
+ base = cache.texture("ter_q1_base")
74
+ if base is None:
75
+ return None
76
+ overlay = cache.texture("ter_q1_tex1")
77
+ detail = overlay or base
78
+ ground = state.menu_ground
79
+ screen_width = float(state.config.screen_width)
80
+ screen_height = float(state.config.screen_height)
81
+ texture_scale = float(state.config.texture_scale)
82
+ if ground is None:
83
+ ground = GroundRenderer(
84
+ texture=base,
85
+ overlay=overlay,
86
+ overlay_detail=detail,
87
+ width=1024,
88
+ height=1024,
89
+ texture_scale=texture_scale,
90
+ screen_width=screen_width,
91
+ screen_height=screen_height,
92
+ )
93
+ state.menu_ground = ground
94
+ regenerate = True
95
+ else:
96
+ scale_changed = abs(float(ground.texture_scale) - texture_scale) > 1e-6
97
+ ground.texture = base
98
+ ground.overlay = overlay
99
+ ground.overlay_detail = detail
100
+ ground.texture_scale = texture_scale
101
+ ground.screen_width = screen_width
102
+ ground.screen_height = screen_height
103
+ if scale_changed:
104
+ regenerate = True
105
+ if regenerate:
106
+ ground.schedule_generate(seed=state.rng.randrange(0, 10_000), layers=3)
107
+ return ground
108
+
109
+
110
+ def _draw_menu_cursor(state: GameState, *, pulse_time: float) -> None:
111
+ cache = _ensure_texture_cache(state)
112
+ particles = cache.get_or_load("particles", "game/particles.jaz").texture
113
+ cursor_tex = cache.get_or_load("ui_cursor", "ui/ui_cursor.jaz").texture
114
+
115
+ mouse = rl.get_mouse_position()
116
+ mouse_x = float(mouse.x)
117
+ mouse_y = float(mouse.y)
118
+ draw_menu_cursor(particles, cursor_tex, x=mouse_x, y=mouse_y, pulse_time=float(pulse_time))
119
+
120
+
121
+ @dataclass(slots=True)
122
+ class MenuEntry:
123
+ slot: int
124
+ row: int
125
+ y: float
126
+ hover_amount: int = 0
127
+ ready_timer_ms: int = 0x100
128
+
129
+
130
+ class MenuView:
131
+ def __init__(self, state: GameState) -> None:
132
+ self._state = state
133
+ self._assets: MenuAssets | None = None
134
+ self._ground: GroundRenderer | None = None
135
+ self._menu_entries: list[MenuEntry] = []
136
+ self._selected_index = 0
137
+ self._focus_timer_ms = 0
138
+ self._hovered_index: int | None = None
139
+ self._full_version = False
140
+ self._timeline_ms = 0
141
+ self._timeline_max_ms = 0
142
+ self._idle_ms = 0
143
+ self._last_mouse_x = 0.0
144
+ self._last_mouse_y = 0.0
145
+ self._cursor_pulse_time = 0.0
146
+ self._widescreen_y_shift = 0.0
147
+ self._menu_screen_width = 0
148
+ self._closing = False
149
+ self._close_action: str | None = None
150
+ self._pending_action: str | None = None
151
+ self._panel_open_sfx_played = False
152
+
153
+ def open(self) -> None:
154
+ layout_w = float(self._state.config.screen_width)
155
+ self._menu_screen_width = int(layout_w)
156
+ self._widescreen_y_shift = self._menu_widescreen_y_shift(layout_w)
157
+ self._assets = load_menu_assets(self._state)
158
+ # Shareware gating is controlled by the --demo flag (see GameState.demo_enabled),
159
+ # not by a persisted config byte.
160
+ self._full_version = not self._state.demo_enabled
161
+ self._menu_entries = self._menu_entries_for_flags(
162
+ full_version=self._full_version,
163
+ mods_available=self._mods_available(),
164
+ other_games=self._other_games_enabled(),
165
+ )
166
+ self._selected_index = 0 if self._menu_entries else -1
167
+ self._focus_timer_ms = 0
168
+ self._hovered_index = None
169
+ self._timeline_ms = 0
170
+ self._idle_ms = 0
171
+ self._cursor_pulse_time = 0.0
172
+ mouse = rl.get_mouse_position()
173
+ self._last_mouse_x = float(mouse.x)
174
+ self._last_mouse_y = float(mouse.y)
175
+ self._closing = False
176
+ self._close_action = None
177
+ self._pending_action = None
178
+ self._panel_open_sfx_played = False
179
+ self._timeline_max_ms = self._menu_max_timeline_ms(
180
+ full_version=self._full_version,
181
+ mods_available=self._mods_available(),
182
+ other_games=self._other_games_enabled(),
183
+ )
184
+ self._init_ground()
185
+ if self._state.audio is not None:
186
+ theme = "crimsonquest" if self._state.demo_enabled else "crimson_theme"
187
+ if self._state.audio.music.active_track != theme:
188
+ stop_music(self._state.audio)
189
+ play_music(self._state.audio, theme)
190
+
191
+ def close(self) -> None:
192
+ self._ground = None
193
+
194
+ def update(self, dt: float) -> None:
195
+ if self._state.audio is not None:
196
+ update_audio(self._state.audio, dt)
197
+ if self._ground is not None:
198
+ self._ground.process_pending()
199
+ self._cursor_pulse_time += min(dt, 0.1) * 1.1
200
+ dt_ms = int(min(dt, 0.1) * 1000.0)
201
+ if self._closing:
202
+ if dt_ms > 0 and self._pending_action is None:
203
+ self._timeline_ms -= dt_ms
204
+ self._focus_timer_ms = max(0, self._focus_timer_ms - dt_ms)
205
+ if self._timeline_ms < 0 and self._close_action is not None:
206
+ self._pending_action = self._close_action
207
+ self._close_action = None
208
+ return
209
+
210
+ if dt_ms > 0:
211
+ mouse = rl.get_mouse_position()
212
+ mouse_x = float(mouse.x)
213
+ mouse_y = float(mouse.y)
214
+ mouse_moved = (mouse_x != self._last_mouse_x) or (mouse_y != self._last_mouse_y)
215
+ if mouse_moved:
216
+ self._last_mouse_x = mouse_x
217
+ self._last_mouse_y = mouse_y
218
+
219
+ any_key = rl.get_key_pressed() != 0
220
+ any_click = (
221
+ rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
222
+ or rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_RIGHT)
223
+ or rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_MIDDLE)
224
+ )
225
+
226
+ if any_key or any_click or mouse_moved:
227
+ self._idle_ms = 0
228
+ else:
229
+ self._idle_ms += dt_ms
230
+
231
+ if dt_ms > 0:
232
+ self._timeline_ms = min(self._timeline_max_ms, self._timeline_ms + dt_ms)
233
+ self._focus_timer_ms = max(0, self._focus_timer_ms - dt_ms)
234
+ if self._timeline_ms >= self._timeline_max_ms:
235
+ self._state.menu_sign_locked = True
236
+ if (not self._panel_open_sfx_played) and (self._state.audio is not None):
237
+ play_sfx(self._state.audio, "sfx_ui_panelclick", rng=self._state.rng)
238
+ self._panel_open_sfx_played = True
239
+ if not self._menu_entries:
240
+ return
241
+
242
+ self._hovered_index = self._hovered_entry_index()
243
+
244
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
245
+ reverse = rl.is_key_down(rl.KeyboardKey.KEY_LEFT_SHIFT) or rl.is_key_down(rl.KeyboardKey.KEY_RIGHT_SHIFT)
246
+ delta = -1 if reverse else 1
247
+ self._selected_index = (self._selected_index + delta) % len(self._menu_entries)
248
+ self._focus_timer_ms = 1000
249
+
250
+ activated_index: int | None = None
251
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) and 0 <= self._selected_index < len(self._menu_entries):
252
+ entry = self._menu_entries[self._selected_index]
253
+ if self._menu_entry_enabled(entry):
254
+ activated_index = self._selected_index
255
+
256
+ if activated_index is None and self._hovered_index is not None:
257
+ if rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
258
+ hovered = self._hovered_index
259
+ entry = self._menu_entries[hovered]
260
+ if self._menu_entry_enabled(entry):
261
+ self._selected_index = hovered
262
+ self._focus_timer_ms = 1000
263
+ activated_index = hovered
264
+
265
+ if activated_index is not None:
266
+ self._activate_menu_entry(activated_index)
267
+ if (
268
+ (not self._closing)
269
+ and self._pending_action is None
270
+ and self._state.demo_enabled
271
+ and self._timeline_ms >= self._timeline_max_ms
272
+ and self._idle_ms >= MENU_DEMO_IDLE_START_MS
273
+ ):
274
+ self._begin_close_transition("start_demo")
275
+ self._update_ready_timers(dt_ms)
276
+ self._update_hover_amounts(dt_ms)
277
+
278
+ def draw(self) -> None:
279
+ rl.clear_background(rl.BLACK)
280
+ if self._ground is not None:
281
+ self._ground.draw(0.0, 0.0)
282
+ _draw_screen_fade(self._state)
283
+ assets = self._assets
284
+ if assets is None:
285
+ return
286
+ self._draw_menu_items()
287
+ self._draw_menu_sign()
288
+ _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
289
+
290
+ def take_action(self) -> str | None:
291
+ action = self._pending_action
292
+ self._pending_action = None
293
+ return action
294
+
295
+ def _activate_menu_entry(self, index: int) -> None:
296
+ if not (0 <= index < len(self._menu_entries)):
297
+ return
298
+ entry = self._menu_entries[index]
299
+ if self._state.audio is not None:
300
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
301
+ self._state.console.log.log(f"menu select: {index} (row {entry.row})")
302
+ self._state.console.log.flush()
303
+ if entry.row == MENU_LABEL_ROW_QUIT:
304
+ self._begin_quit_transition()
305
+ elif entry.row == MENU_LABEL_ROW_PLAY_GAME:
306
+ self._begin_close_transition("open_play_game")
307
+ elif entry.row == MENU_LABEL_ROW_OPTIONS:
308
+ self._begin_close_transition("open_options")
309
+ elif entry.row == MENU_LABEL_ROW_STATISTICS:
310
+ self._begin_close_transition("open_statistics")
311
+ elif entry.row == MENU_LABEL_ROW_MODS:
312
+ self._begin_close_transition("open_mods")
313
+ elif entry.row == MENU_LABEL_ROW_OTHER_GAMES:
314
+ self._begin_close_transition("open_other_games")
315
+
316
+ def _begin_close_transition(self, action: str) -> None:
317
+ if self._closing:
318
+ return
319
+ self._closing = True
320
+ self._close_action = action
321
+
322
+ def _begin_quit_transition(self) -> None:
323
+ self._state.menu_sign_locked = False
324
+ self._begin_close_transition("quit_after_demo" if self._state.demo_enabled else "quit_app")
325
+
326
+ def _init_ground(self) -> None:
327
+ self._ground = ensure_menu_ground(self._state)
328
+
329
+ def _menu_entries_for_flags(
330
+ self,
331
+ full_version: bool,
332
+ mods_available: bool,
333
+ other_games: bool,
334
+ ) -> list[MenuEntry]:
335
+ rows = self._menu_label_rows(full_version, other_games)
336
+ slot_ys = self._menu_slot_ys(other_games, self._widescreen_y_shift)
337
+ active = self._menu_slot_active(full_version, mods_available, other_games)
338
+ entries: list[MenuEntry] = []
339
+ for slot, (row, y, enabled) in enumerate(zip(rows, slot_ys, active, strict=False)):
340
+ if not enabled:
341
+ continue
342
+ entries.append(MenuEntry(slot=slot, row=row, y=y))
343
+ return entries
344
+
345
+ @staticmethod
346
+ def _menu_label_rows(_full_version: bool, other_games: bool) -> list[int]:
347
+ # Label atlas rows in ui_itemTexts.jaz:
348
+ # 0 BUY NOW (unused in rewrite), 1 PLAY GAME, 2 OPTIONS, 3 STATISTICS, 4 MODS,
349
+ # 5 OTHER GAMES, 6 QUIT, 7 BACK
350
+ top = 4
351
+ if other_games:
352
+ return [top, 1, 2, 3, 5, 6]
353
+ # ui_menu_layout_init swaps table idx 6/7 depending on config var 100:
354
+ # when empty, QUIT becomes idx 6 and the idx 7 element is inactive.
355
+ return [top, 1, 2, 3, 6, 7]
356
+
357
+ @staticmethod
358
+ def _menu_slot_ys(_other_games: bool, y_shift: float) -> list[float]:
359
+ ys = [
360
+ MENU_LABEL_BASE_Y,
361
+ MENU_LABEL_BASE_Y + MENU_LABEL_STEP,
362
+ MENU_LABEL_BASE_Y + MENU_LABEL_STEP * 2.0,
363
+ MENU_LABEL_BASE_Y + MENU_LABEL_STEP * 3.0,
364
+ MENU_LABEL_BASE_Y + MENU_LABEL_STEP * 4.0,
365
+ MENU_LABEL_BASE_Y + MENU_LABEL_STEP * 5.0,
366
+ ]
367
+ return [y + y_shift for y in ys]
368
+
369
+ @staticmethod
370
+ def _menu_slot_active(
371
+ _full_version: bool,
372
+ mods_available: bool,
373
+ other_games: bool,
374
+ ) -> list[bool]:
375
+ show_top = mods_available
376
+ if other_games:
377
+ return [show_top, True, True, True, True, True]
378
+ return [show_top, True, True, True, True, False]
379
+
380
+ def _draw_menu_items(self) -> None:
381
+ assets = self._assets
382
+ if assets is None or assets.labels is None or not self._menu_entries:
383
+ return
384
+ item = assets.item
385
+ if item is None:
386
+ return
387
+ label_tex = assets.labels
388
+ item_w = float(item.width)
389
+ item_h = float(item.height)
390
+ fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
391
+ # Matches ui_elements_update_and_render reverse table iteration:
392
+ # later entries draw first, earlier entries draw last (on top).
393
+ for idx in range(len(self._menu_entries) - 1, -1, -1):
394
+ entry = self._menu_entries[idx]
395
+ pos_x = self._menu_slot_pos_x(entry.slot)
396
+ pos_y = entry.y
397
+ angle_rad, slide_x = self._ui_element_anim(
398
+ index=entry.slot + 2,
399
+ start_ms=self._menu_slot_start_ms(entry.slot),
400
+ end_ms=self._menu_slot_end_ms(entry.slot),
401
+ width=item_w,
402
+ )
403
+ _ = slide_x # slide is ignored for render_mode==0 (transform) elements
404
+ item_scale, local_y_shift = self._menu_item_scale(entry.slot)
405
+ offset_x = MENU_ITEM_OFFSET_X * item_scale
406
+ offset_y = MENU_ITEM_OFFSET_Y * item_scale - local_y_shift
407
+ dst = rl.Rectangle(
408
+ pos_x,
409
+ pos_y,
410
+ item_w * item_scale,
411
+ item_h * item_scale,
412
+ )
413
+ origin = rl.Vector2(-offset_x, -offset_y)
414
+ rotation_deg = math.degrees(angle_rad)
415
+ if fx_detail:
416
+ self._draw_ui_quad_shadow(
417
+ texture=item,
418
+ src=rl.Rectangle(0.0, 0.0, item_w, item_h),
419
+ dst=rl.Rectangle(dst.x + UI_SHADOW_OFFSET, dst.y + UI_SHADOW_OFFSET, dst.width, dst.height),
420
+ origin=origin,
421
+ rotation_deg=rotation_deg,
422
+ )
423
+ self._draw_ui_quad(
424
+ texture=item,
425
+ src=rl.Rectangle(0.0, 0.0, item_w, item_h),
426
+ dst=dst,
427
+ origin=origin,
428
+ rotation_deg=rotation_deg,
429
+ tint=rl.WHITE,
430
+ )
431
+ counter_value = entry.hover_amount
432
+ if idx == self._selected_index and self._focus_timer_ms > 0:
433
+ counter_value = self._focus_timer_ms
434
+ alpha = self._label_alpha(counter_value)
435
+ tint = rl.Color(255, 255, 255, alpha)
436
+ src = rl.Rectangle(
437
+ 0.0,
438
+ float(entry.row) * MENU_LABEL_ROW_HEIGHT,
439
+ MENU_LABEL_WIDTH,
440
+ MENU_LABEL_ROW_HEIGHT,
441
+ )
442
+ label_offset_x = MENU_LABEL_OFFSET_X * item_scale
443
+ label_offset_y = MENU_LABEL_OFFSET_Y * item_scale - local_y_shift
444
+ label_dst = rl.Rectangle(
445
+ pos_x,
446
+ pos_y,
447
+ MENU_LABEL_WIDTH * item_scale,
448
+ MENU_LABEL_HEIGHT * item_scale,
449
+ )
450
+ label_origin = rl.Vector2(-label_offset_x, -label_offset_y)
451
+ self._draw_ui_quad(
452
+ texture=label_tex,
453
+ src=src,
454
+ dst=label_dst,
455
+ origin=label_origin,
456
+ rotation_deg=rotation_deg,
457
+ tint=tint,
458
+ )
459
+ if self._menu_entry_enabled(entry):
460
+ glow_alpha = alpha
461
+ if 0 <= entry.ready_timer_ms < 0x100:
462
+ glow_alpha = 0xFF - (entry.ready_timer_ms // 2)
463
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
464
+ self._draw_ui_quad(
465
+ texture=label_tex,
466
+ src=src,
467
+ dst=label_dst,
468
+ origin=label_origin,
469
+ rotation_deg=rotation_deg,
470
+ tint=rl.Color(255, 255, 255, glow_alpha),
471
+ )
472
+ rl.end_blend_mode()
473
+
474
+ def _mods_available(self) -> bool:
475
+ mods_dir = self._state.base_dir / "mods"
476
+ if not mods_dir.exists():
477
+ return False
478
+ return any(mods_dir.glob("*.dll"))
479
+
480
+ def _other_games_enabled(self) -> bool:
481
+ # Original game checks a config string via grim_get_config_var(100).
482
+ # Our config-var system is not implemented yet; allow a simple env opt-in.
483
+ return os.getenv("CRIMSON_GRIM_CONFIG_VAR_100", "").strip() != ""
484
+
485
+ def _hovered_entry_index(self) -> int | None:
486
+ if not self._menu_entries:
487
+ return None
488
+ mouse = rl.get_mouse_position()
489
+ mouse_x = float(mouse.x)
490
+ mouse_y = float(mouse.y)
491
+ for idx, entry in enumerate(self._menu_entries):
492
+ if not self._menu_entry_enabled(entry):
493
+ continue
494
+ left, top, right, bottom = self._menu_item_bounds(entry)
495
+ if left <= mouse_x <= right and top <= mouse_y <= bottom:
496
+ return idx
497
+ return None
498
+
499
+ def _update_ready_timers(self, dt_ms: int) -> None:
500
+ for entry in self._menu_entries:
501
+ if entry.ready_timer_ms < 0x100:
502
+ entry.ready_timer_ms = min(0x100, entry.ready_timer_ms + dt_ms)
503
+
504
+ def _update_hover_amounts(self, dt_ms: int) -> None:
505
+ hovered_index = self._hovered_index
506
+ for idx, entry in enumerate(self._menu_entries):
507
+ hover = hovered_index is not None and idx == hovered_index
508
+ if hover:
509
+ entry.hover_amount += dt_ms * 6
510
+ else:
511
+ entry.hover_amount -= dt_ms * 2
512
+ entry.hover_amount = max(0, min(1000, entry.hover_amount))
513
+
514
+ @staticmethod
515
+ def _label_alpha(counter_value: int) -> int:
516
+ # ui_element_render: alpha = 100 + floor(counter_value * 155 / 1000)
517
+ return 100 + (counter_value * 155) // 1000
518
+
519
+ def _menu_entry_enabled(self, entry: MenuEntry) -> bool:
520
+ return self._timeline_ms >= self._menu_slot_start_ms(entry.slot)
521
+
522
+ @staticmethod
523
+ def _menu_widescreen_y_shift(screen_w: float) -> float:
524
+ # ((screen_width / 640.0) * 150.0) - 150.0
525
+ return (screen_w * 0.0015625 * 150.0) - 150.0
526
+
527
+ def _menu_item_scale(self, slot: int) -> tuple[float, float]:
528
+ if self._menu_screen_width < 641:
529
+ return 0.9, float(slot) * 11.0
530
+ return 1.0, 0.0
531
+
532
+ def _menu_item_bounds(self, entry: MenuEntry) -> tuple[float, float, float, float]:
533
+ # FUN_0044fb50: inset bounds derived from quad0 v0/v2 and pos_x/pos_y.
534
+ assets = self._assets
535
+ if assets is None or assets.item is None:
536
+ return (0.0, 0.0, 0.0, 0.0)
537
+ item_w = float(assets.item.width)
538
+ item_h = float(assets.item.height)
539
+ item_scale, local_y_shift = self._menu_item_scale(entry.slot)
540
+ x0 = MENU_ITEM_OFFSET_X * item_scale
541
+ y0 = MENU_ITEM_OFFSET_Y * item_scale - local_y_shift
542
+ x2 = (MENU_ITEM_OFFSET_X + item_w) * item_scale
543
+ y2 = (MENU_ITEM_OFFSET_Y + item_h) * item_scale - local_y_shift
544
+ w = x2 - x0
545
+ h = y2 - y0
546
+ pos_x = self._menu_slot_pos_x(entry.slot)
547
+ pos_y = entry.y
548
+ left = pos_x + x0 + w * 0.54
549
+ top = pos_y + y0 + h * 0.28
550
+ right = pos_x + x2 - w * 0.05
551
+ bottom = pos_y + y2 - h * 0.10
552
+ return left, top, right, bottom
553
+
554
+ @staticmethod
555
+ def _menu_slot_pos_x(slot: int) -> float:
556
+ # ui_menu_layout_init: subtract 20, 40, ... from later menu items
557
+ return MENU_LABEL_BASE_X - float(slot * 20)
558
+
559
+ @staticmethod
560
+ def _menu_slot_start_ms(slot: int) -> int:
561
+ # ui_menu_layout_init: start_time_ms is the fully-visible time.
562
+ return (slot + 2) * 100 + 300
563
+
564
+ @classmethod
565
+ def _menu_slot_end_ms(cls, slot: int) -> int:
566
+ # ui_menu_layout_init: end_time_ms is the fully-hidden time.
567
+ return (slot + 2) * 100
568
+
569
+ @staticmethod
570
+ def _menu_max_timeline_ms(full_version: bool, mods_available: bool, other_games: bool) -> int:
571
+ del full_version
572
+ max_ms = 300 # sign element at index 0
573
+ show_top = mods_available
574
+ slot_active = [show_top, True, True, True, True, other_games]
575
+ for slot, active in enumerate(slot_active):
576
+ if not active:
577
+ continue
578
+ max_ms = max(max_ms, (slot + 2) * 100 + 300)
579
+ return max_ms
580
+
581
+ def _ui_element_anim(
582
+ self,
583
+ *,
584
+ index: int,
585
+ start_ms: int,
586
+ end_ms: int,
587
+ width: float,
588
+ ) -> tuple[float, float]:
589
+ # Matches ui_element_update: angle lerps pi/2 -> 0 over [end_ms, start_ms].
590
+ # Direction flag (element+0x314) appears to be 0 for main menu elements.
591
+ if start_ms <= end_ms or width <= 0.0:
592
+ return 0.0, 0.0
593
+ t = self._timeline_ms
594
+ if t < end_ms:
595
+ angle = 1.5707964
596
+ offset_x = -abs(width)
597
+ elif t < start_ms:
598
+ elapsed = t - end_ms
599
+ span = float(start_ms - end_ms)
600
+ p = float(elapsed) / span
601
+ angle = 1.5707964 * (1.0 - p)
602
+ offset_x = -((1.0 - p) * abs(width))
603
+ else:
604
+ angle = 0.0
605
+ offset_x = 0.0
606
+ if index == 0:
607
+ angle = -abs(angle)
608
+ return angle, offset_x
609
+
610
+ @staticmethod
611
+ def _draw_ui_quad(
612
+ *,
613
+ texture: rl.Texture2D,
614
+ src: rl.Rectangle,
615
+ dst: rl.Rectangle,
616
+ origin: rl.Vector2,
617
+ rotation_deg: float,
618
+ tint: rl.Color,
619
+ ) -> None:
620
+ rl.draw_texture_pro(texture, src, dst, origin, rotation_deg, tint)
621
+
622
+ @staticmethod
623
+ def _draw_ui_quad_shadow(
624
+ *,
625
+ texture: rl.Texture2D,
626
+ src: rl.Rectangle,
627
+ dst: rl.Rectangle,
628
+ origin: rl.Vector2,
629
+ rotation_deg: float,
630
+ ) -> None:
631
+ # NOTE: raylib/rlgl tracks custom blend factors as state; some backends
632
+ # only apply them when switching the blend mode.
633
+ rl.rl_set_blend_factors_separate(
634
+ rl.RL_ZERO,
635
+ rl.RL_ONE_MINUS_SRC_ALPHA,
636
+ rl.RL_ZERO,
637
+ rl.RL_ONE,
638
+ rl.RL_FUNC_ADD,
639
+ rl.RL_FUNC_ADD,
640
+ )
641
+ rl.begin_blend_mode(rl.BLEND_CUSTOM_SEPARATE)
642
+ rl.rl_set_blend_factors_separate(
643
+ rl.RL_ZERO,
644
+ rl.RL_ONE_MINUS_SRC_ALPHA,
645
+ rl.RL_ZERO,
646
+ rl.RL_ONE,
647
+ rl.RL_FUNC_ADD,
648
+ rl.RL_FUNC_ADD,
649
+ )
650
+ rl.draw_texture_pro(texture, src, dst, origin, rotation_deg, UI_SHADOW_TINT)
651
+ rl.end_blend_mode()
652
+
653
+ def _draw_menu_sign(self) -> None:
654
+ assets = self._assets
655
+ if assets is None or assets.sign is None:
656
+ return
657
+ screen_w = float(self._state.config.screen_width)
658
+ scale, shift_x = self._sign_layout_scale(int(screen_w))
659
+ pos_x = screen_w + MENU_SIGN_POS_X_PAD
660
+ pos_y = MENU_SIGN_POS_Y if screen_w > MENU_SCALE_SMALL_THRESHOLD else MENU_SIGN_POS_Y_SMALL
661
+ sign_w = MENU_SIGN_WIDTH * scale
662
+ sign_h = MENU_SIGN_HEIGHT * scale
663
+ offset_x = MENU_SIGN_OFFSET_X * scale + shift_x
664
+ offset_y = MENU_SIGN_OFFSET_Y * scale
665
+ rotation_deg = 0.0
666
+ if not self._state.menu_sign_locked:
667
+ angle_rad, slide_x = self._ui_element_anim(
668
+ index=0,
669
+ start_ms=300,
670
+ end_ms=0,
671
+ width=sign_w,
672
+ )
673
+ _ = slide_x # slide is ignored for render_mode==0 (transform) elements
674
+ rotation_deg = math.degrees(angle_rad)
675
+ sign = assets.sign
676
+ fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
677
+ if fx_detail:
678
+ self._draw_ui_quad_shadow(
679
+ texture=sign,
680
+ src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
681
+ dst=rl.Rectangle(pos_x + UI_SHADOW_OFFSET, pos_y + UI_SHADOW_OFFSET, sign_w, sign_h),
682
+ origin=rl.Vector2(-offset_x, -offset_y),
683
+ rotation_deg=rotation_deg,
684
+ )
685
+ self._draw_ui_quad(
686
+ texture=sign,
687
+ src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
688
+ dst=rl.Rectangle(pos_x, pos_y, sign_w, sign_h),
689
+ origin=rl.Vector2(-offset_x, -offset_y),
690
+ rotation_deg=rotation_deg,
691
+ tint=rl.WHITE,
692
+ )
693
+
694
+ @staticmethod
695
+ def _sign_layout_scale(width: int) -> tuple[float, float]:
696
+ if width <= MENU_SCALE_SMALL_THRESHOLD:
697
+ return MENU_SCALE_SMALL, MENU_SCALE_SHIFT
698
+ if MENU_SCALE_LARGE_MIN <= width <= MENU_SCALE_LARGE_MAX:
699
+ return MENU_SCALE_LARGE, MENU_SCALE_SHIFT
700
+ return 1.0, 0.0
@@ -0,0 +1 @@
1
+ from __future__ import annotations