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,627 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ import pyray as rl
7
+
8
+ from grim.audio import update_audio
9
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
10
+
11
+ from ...debug import debug_enabled
12
+
13
+ from ..menu import (
14
+ MENU_LABEL_ROW_HEIGHT,
15
+ MENU_LABEL_ROW_PLAY_GAME,
16
+ MENU_LABEL_WIDTH,
17
+ MENU_PANEL_OFFSET_X,
18
+ MENU_PANEL_OFFSET_Y,
19
+ MENU_PANEL_WIDTH,
20
+ MenuView,
21
+ _draw_menu_cursor,
22
+ )
23
+ from ..transitions import _draw_screen_fade
24
+ from .base import PANEL_TIMELINE_END_MS, PANEL_TIMELINE_START_MS, PanelMenuView
25
+
26
+ if TYPE_CHECKING:
27
+ from ...game import GameState
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class _PlayGameModeEntry:
32
+ key: str
33
+ label: str
34
+ tooltip: str
35
+ action: str
36
+ game_mode: int | None = None
37
+ show_count: bool = False
38
+
39
+
40
+ class PlayGameMenuView(PanelMenuView):
41
+ """Play Game mode select panel.
42
+
43
+ Layout and gating are based on `sub_44ed80` (crimsonland.exe).
44
+ """
45
+
46
+ _PLAYER_COUNT_LABELS = ("1 player", "2 players", "3 players", "4 players")
47
+
48
+ def __init__(self, state: GameState) -> None:
49
+ super().__init__(
50
+ state,
51
+ title="Play Game",
52
+ back_pos_y=462.0,
53
+ )
54
+ self._small_font: SmallFontData | None = None
55
+ self._button_sm: rl.Texture2D | None = None
56
+ self._button_md: rl.Texture2D | None = None
57
+ self._drop_on: rl.Texture2D | None = None
58
+ self._drop_off: rl.Texture2D | None = None
59
+
60
+ self._player_list_open = False
61
+ self._dirty = False
62
+
63
+ # Hover fade timers for tooltips (0..1000ms-ish; original uses ~0.0009 alpha scale).
64
+ self._tooltip_ms: dict[str, int] = {}
65
+
66
+ def open(self) -> None:
67
+ super().open()
68
+ cache = self._ensure_cache()
69
+ self._button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
70
+ self._button_md = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
71
+ self._drop_on = cache.get_or_load("ui_dropOn", "ui/ui_dropDownOn.jaz").texture
72
+ self._drop_off = cache.get_or_load("ui_dropOff", "ui/ui_dropDownOff.jaz").texture
73
+ self._player_list_open = False
74
+ self._dirty = False
75
+ self._tooltip_ms.clear()
76
+
77
+ def update(self, dt: float) -> None:
78
+ if self._state.audio is not None:
79
+ update_audio(self._state.audio, dt)
80
+ if self._ground is not None:
81
+ self._ground.process_pending()
82
+ self._cursor_pulse_time += min(dt, 0.1) * 1.1
83
+ dt_ms = int(min(dt, 0.1) * 1000.0)
84
+
85
+ # Close transition (matches PanelMenuView).
86
+ if self._closing:
87
+ if dt_ms > 0 and self._pending_action is None:
88
+ self._timeline_ms -= dt_ms
89
+ if self._timeline_ms < 0 and self._close_action is not None:
90
+ self._pending_action = self._close_action
91
+ self._close_action = None
92
+ return
93
+
94
+ if dt_ms > 0:
95
+ self._timeline_ms = min(self._timeline_max_ms, self._timeline_ms + dt_ms)
96
+ if self._timeline_ms >= self._timeline_max_ms:
97
+ self._state.menu_sign_locked = True
98
+
99
+ entry = self._entry
100
+ if entry is None:
101
+ return
102
+
103
+ enabled = self._entry_enabled(entry)
104
+ hovered_back = enabled and self._hovered_entry(entry)
105
+ self._hovered = hovered_back
106
+
107
+ # ESC always goes back; Enter should not auto-back on this screen.
108
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE) and enabled:
109
+ self._begin_close_transition(self._back_action)
110
+ if enabled and hovered_back and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
111
+ self._begin_close_transition(self._back_action)
112
+
113
+ if hovered_back:
114
+ entry.hover_amount += dt_ms * 6
115
+ else:
116
+ entry.hover_amount -= dt_ms * 2
117
+ entry.hover_amount = max(0, min(1000, entry.hover_amount))
118
+
119
+ if entry.ready_timer_ms < 0x100:
120
+ entry.ready_timer_ms = min(0x100, entry.ready_timer_ms + dt_ms)
121
+
122
+ if not enabled:
123
+ return
124
+
125
+ layout = self._content_layout()
126
+ scale = layout["scale"]
127
+ base_x = layout["base_x"]
128
+ base_y = layout["base_y"]
129
+ drop_x = layout["drop_x"]
130
+ drop_y = layout["drop_y"]
131
+
132
+ consumed_click = self._update_player_count(drop_x, drop_y, scale)
133
+ if consumed_click:
134
+ return
135
+
136
+ # Mode buttons (disabled while the player dropdown is open).
137
+ if self._player_list_open:
138
+ return
139
+ y = base_y
140
+ entries, y_step, y_start, y_end = self._mode_entries()
141
+ y += y_start * scale
142
+ for mode in entries:
143
+ clicked, hovered = self._update_mode_button(mode, base_x, y, scale)
144
+ self._update_tooltip_timer(mode.key, hovered, dt_ms)
145
+ if clicked:
146
+ self._activate_mode(mode)
147
+ return
148
+ y += y_step * scale
149
+
150
+ # Decay timers for modes that aren't visible right now.
151
+ visible = {m.key for m in entries}
152
+ for key in list(self._tooltip_ms):
153
+ if key in visible:
154
+ continue
155
+ self._tooltip_ms[key] = max(0, self._tooltip_ms[key] - dt_ms * 2)
156
+
157
+ def draw(self) -> None:
158
+ rl.clear_background(rl.BLACK)
159
+ if self._ground is not None:
160
+ self._ground.draw(0.0, 0.0)
161
+ _draw_screen_fade(self._state)
162
+ assets = self._assets
163
+ entry = self._entry
164
+ if assets is None or entry is None:
165
+ return
166
+
167
+ self._draw_panel()
168
+ self._draw_entry(entry)
169
+ self._draw_sign()
170
+ self._draw_contents()
171
+ _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
172
+
173
+ def _begin_close_transition(self, action: str) -> None:
174
+ if self._dirty:
175
+ try:
176
+ self._state.config.save()
177
+ except Exception:
178
+ pass
179
+ self._dirty = False
180
+ super()._begin_close_transition(action)
181
+
182
+ def _ensure_small_font(self) -> SmallFontData:
183
+ if self._small_font is not None:
184
+ return self._small_font
185
+ missing_assets: list[str] = []
186
+ self._small_font = load_small_font(self._state.assets_dir, missing_assets)
187
+ return self._small_font
188
+
189
+ def _content_layout(self) -> dict[str, float]:
190
+ panel_scale, _local_shift = self._menu_item_scale(0)
191
+ panel_w = MENU_PANEL_WIDTH * panel_scale
192
+ _angle_rad, slide_x = MenuView._ui_element_anim(
193
+ self,
194
+ index=1,
195
+ start_ms=PANEL_TIMELINE_START_MS,
196
+ end_ms=PANEL_TIMELINE_END_MS,
197
+ width=panel_w,
198
+ )
199
+ panel_x = self._panel_pos_x + slide_x
200
+ panel_y = self._panel_pos_y + self._widescreen_y_shift
201
+ origin_x = -(MENU_PANEL_OFFSET_X * panel_scale)
202
+ origin_y = -(MENU_PANEL_OFFSET_Y * panel_scale)
203
+ panel_left = panel_x - origin_x
204
+ panel_top = panel_y - origin_y
205
+
206
+ # `sub_44ed80`:
207
+ # xy = panel_offset_x + panel_x + 330 - 64 (+ animated X offset)
208
+ # var_1c = panel_offset_y + panel_y + 50
209
+ base_x = panel_left + 266.0 * panel_scale
210
+ base_y = panel_top + 50.0 * panel_scale
211
+
212
+ drop_x = base_x + 80.0 * panel_scale
213
+ drop_y = base_y + 1.0 * panel_scale
214
+
215
+ return {
216
+ "panel_left": panel_left,
217
+ "panel_top": panel_top,
218
+ "scale": panel_scale,
219
+ "base_x": base_x,
220
+ "base_y": base_y,
221
+ "drop_x": drop_x,
222
+ "drop_y": drop_y,
223
+ }
224
+
225
+ def _quests_total_played(self) -> int:
226
+ counts = self._state.status.data.get("quest_play_counts", [])
227
+ if not isinstance(counts, list) or not counts:
228
+ return 0
229
+ # `sub_44ed80` sums 40 ints from game_status_blob+0x104..0x1a4.
230
+ # Our `quest_play_counts` array starts at blob+0xd8, so this is indices 11..50.
231
+ return int(sum(int(v) for v in counts[11:51]))
232
+
233
+ def _mode_entries(self) -> tuple[list[_PlayGameModeEntry], float, float, float]:
234
+ config = self._state.config
235
+ status = self._state.status
236
+
237
+ # Clamp to a valid range; older configs in the repo can contain 0 here,
238
+ # which would incorrectly hide the Tutorial entry (it is gated on == 1).
239
+ player_count = int(config.data.get("player_count", 1))
240
+ if player_count < 1:
241
+ player_count = 1
242
+ if player_count > len(self._PLAYER_COUNT_LABELS):
243
+ player_count = len(self._PLAYER_COUNT_LABELS)
244
+ quest_unlock = int(status.quest_unlock_index)
245
+ full_version = not self._state.demo_enabled
246
+
247
+ quests_total = self._quests_total_played()
248
+ rush_total = int(status.mode_play_count("rush"))
249
+ survival_total = int(status.mode_play_count("survival"))
250
+ # Matches the tutorial placement gating in `sub_44ed80` (excludes Typ-o).
251
+ main_total = quests_total + rush_total + survival_total
252
+
253
+ # `sub_44ed80` uses tighter spacing when quest_unlock>=40 and player_count==1.
254
+ tight_spacing = not (quest_unlock < 0x28 or player_count > 1)
255
+ y_step = 28.0 if tight_spacing else 32.0
256
+ y_start = 26.0 if tight_spacing else 32.0
257
+
258
+ has_typo = tight_spacing and full_version and player_count == 1
259
+ show_tutorial = player_count == 1
260
+
261
+ entries: list[_PlayGameModeEntry] = []
262
+ if show_tutorial and main_total <= 0:
263
+ entries.append(
264
+ _PlayGameModeEntry(
265
+ key="tutorial",
266
+ label="Tutorial",
267
+ tooltip="Learn how to play Crimsonland.",
268
+ action="start_tutorial",
269
+ game_mode=8,
270
+ )
271
+ )
272
+
273
+ entries.extend(
274
+ [
275
+ _PlayGameModeEntry(
276
+ key="quests",
277
+ label=" Quests ",
278
+ tooltip="Unlock new weapons and perks in Quest mode.",
279
+ action="open_quests",
280
+ show_count=True,
281
+ ),
282
+ _PlayGameModeEntry(
283
+ key="rush",
284
+ label=" Rush ",
285
+ tooltip="Face a rush of aliens in Rush mode.",
286
+ action="start_rush",
287
+ game_mode=2,
288
+ show_count=True,
289
+ ),
290
+ _PlayGameModeEntry(
291
+ key="survival",
292
+ label="Survival",
293
+ tooltip="Gain perks and weapons and fight back.",
294
+ action="start_survival",
295
+ game_mode=1,
296
+ show_count=True,
297
+ ),
298
+ ]
299
+ )
300
+
301
+ if has_typo:
302
+ entries.append(
303
+ _PlayGameModeEntry(
304
+ key="typo",
305
+ label="Typ'o'Shooter",
306
+ tooltip="Use your typing skills as the weapon to lay\nthem down.",
307
+ action="start_typo",
308
+ game_mode=4,
309
+ show_count=True,
310
+ )
311
+ )
312
+
313
+ if show_tutorial and main_total > 0:
314
+ entries.append(
315
+ _PlayGameModeEntry(
316
+ key="tutorial",
317
+ label="Tutorial",
318
+ tooltip="Learn how to play Crimsonland.",
319
+ action="start_tutorial",
320
+ game_mode=8,
321
+ )
322
+ )
323
+
324
+ # The y after the last row is used as a tooltip anchor in `sub_44ed80`.
325
+ y_end = y_start + y_step * float(len(entries))
326
+ return entries, y_step, y_start, y_end
327
+
328
+ def _button_tex_for_label(self, label: str, scale: float) -> rl.Texture2D | None:
329
+ md = self._button_md
330
+ sm = self._button_sm
331
+ if md is None:
332
+ return sm
333
+ if sm is None:
334
+ return md
335
+
336
+ # `ui_button_update` picks between button sizes based on rendered label width.
337
+ font = self._ensure_small_font()
338
+ label_w = measure_small_text_width(font, label, 1.0 * scale)
339
+ return sm if label_w < 40.0 * scale else md
340
+
341
+ def _mode_button_rect(self, label: str, x: float, y: float, scale: float) -> rl.Rectangle:
342
+ tex = self._button_tex_for_label(label, scale)
343
+ if tex is None:
344
+ return rl.Rectangle(x, y, 145.0 * scale, 32.0 * scale)
345
+ return rl.Rectangle(x, y, float(tex.width) * scale, float(tex.height) * scale)
346
+
347
+ def _update_mode_button(self, mode: _PlayGameModeEntry, x: float, y: float, scale: float) -> tuple[bool, bool]:
348
+ rect = self._mode_button_rect(mode.label, x, y, scale)
349
+ mouse = rl.get_mouse_position()
350
+ hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
351
+ clicked = hovered and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
352
+ return clicked, hovered
353
+
354
+ def _activate_mode(self, mode: _PlayGameModeEntry) -> None:
355
+ if mode.game_mode is not None:
356
+ self._state.config.data["game_mode"] = int(mode.game_mode)
357
+ self._dirty = True
358
+ self._begin_close_transition(mode.action)
359
+
360
+ def _update_tooltip_timer(self, key: str, hovered: bool, dt_ms: int) -> None:
361
+ value = int(self._tooltip_ms.get(key, 0))
362
+ if hovered:
363
+ value += dt_ms * 6
364
+ else:
365
+ value -= dt_ms * 2
366
+ self._tooltip_ms[key] = max(0, min(1000, value))
367
+
368
+ def _player_count_widget_layout(self, x: float, y: float, scale: float) -> dict[str, float]:
369
+ """Return Play Game player-count dropdown metrics.
370
+
371
+ `ui_list_widget_update` (0x43efc0):
372
+ - width = max(label_w) + 0x30
373
+ - header height = 16
374
+ - open height = (count * 16) + 0x18
375
+ - arrow icon = 16x16 at (x + width - 16 - 1, y)
376
+ - selected label at (x + 4, y + 1)
377
+ - list rows start at y + 17, step 16
378
+ """
379
+ font = self._ensure_small_font()
380
+ text_scale = 1.0 * scale
381
+ max_label_w = 0.0
382
+ for label in self._PLAYER_COUNT_LABELS:
383
+ max_label_w = max(max_label_w, measure_small_text_width(font, label, text_scale))
384
+ width = max_label_w + 48.0 * scale
385
+ header_h = 16.0 * scale
386
+ row_h = 16.0 * scale
387
+ full_h = (float(len(self._PLAYER_COUNT_LABELS)) * 16.0 + 24.0) * scale
388
+ arrow = 16.0 * scale
389
+ return {
390
+ "x": x,
391
+ "y": y,
392
+ "w": width,
393
+ "header_h": header_h,
394
+ "row_h": row_h,
395
+ "rows_y0": y + 17.0 * scale,
396
+ "full_h": full_h,
397
+ "arrow_x": x + width - arrow - 1.0 * scale,
398
+ "arrow_y": y,
399
+ "arrow_w": arrow,
400
+ "arrow_h": arrow,
401
+ "text_x": x + 4.0 * scale,
402
+ "text_y": y + 1.0 * scale,
403
+ "text_scale": text_scale,
404
+ }
405
+
406
+ def _update_player_count(self, x: float, y: float, scale: float) -> bool:
407
+ config = self._state.config
408
+ layout = self._player_count_widget_layout(x, y, scale)
409
+ w = layout["w"]
410
+ header_h = layout["header_h"]
411
+ row_h = layout["row_h"]
412
+ rows_y0 = layout["rows_y0"]
413
+ full_h = layout["full_h"]
414
+
415
+ mouse = rl.get_mouse_position()
416
+ hovered_header = x <= mouse.x <= x + w and y <= mouse.y <= y + header_h
417
+ if hovered_header and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
418
+ self._player_list_open = not self._player_list_open
419
+ return True
420
+
421
+ if not self._player_list_open:
422
+ return False
423
+
424
+ # Close if we click outside the dropdown + list.
425
+ list_hovered = x <= mouse.x <= x + w and y <= mouse.y <= y + full_h
426
+ if rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT) and not list_hovered:
427
+ self._player_list_open = False
428
+ return True
429
+
430
+ for idx, label in enumerate(self._PLAYER_COUNT_LABELS):
431
+ del label
432
+ item_y = rows_y0 + row_h * float(idx)
433
+ item_hovered = x <= mouse.x <= x + w and item_y <= mouse.y <= item_y + row_h
434
+ if item_hovered and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
435
+ config.data["player_count"] = idx + 1
436
+ self._dirty = True
437
+ self._player_list_open = False
438
+ return True
439
+ return False
440
+
441
+ def _draw_contents(self) -> None:
442
+ assets = self._assets
443
+ if assets is None:
444
+ return
445
+ labels_tex = assets.labels
446
+ layout = self._content_layout()
447
+ panel_left = layout["panel_left"]
448
+ panel_top = layout["panel_top"]
449
+ base_x = layout["base_x"]
450
+ base_y = layout["base_y"]
451
+ drop_x = layout["drop_x"]
452
+ drop_y = layout["drop_y"]
453
+ scale = layout["scale"]
454
+
455
+ font = self._ensure_small_font()
456
+ text_scale = 1.0 * scale
457
+ text_color = rl.Color(255, 255, 255, int(255 * 0.8))
458
+
459
+ # Panel title label from ui_itemTexts (same as OptionsMenuView).
460
+ if labels_tex is not None:
461
+ src = rl.Rectangle(
462
+ 0.0,
463
+ float(MENU_LABEL_ROW_PLAY_GAME) * MENU_LABEL_ROW_HEIGHT,
464
+ MENU_LABEL_WIDTH,
465
+ MENU_LABEL_ROW_HEIGHT,
466
+ )
467
+ dst = rl.Rectangle(
468
+ panel_left + 212.0 * scale,
469
+ panel_top + 32.0 * scale,
470
+ MENU_LABEL_WIDTH * scale,
471
+ MENU_LABEL_ROW_HEIGHT * scale,
472
+ )
473
+ MenuView._draw_ui_quad(
474
+ texture=labels_tex,
475
+ src=src,
476
+ dst=dst,
477
+ origin=rl.Vector2(0.0, 0.0),
478
+ rotation_deg=0.0,
479
+ tint=rl.WHITE,
480
+ )
481
+ else:
482
+ rl.draw_text(self._title, int(panel_left + 212.0 * scale), int(panel_top + 32.0 * scale), int(24 * scale), rl.WHITE)
483
+
484
+ self._draw_player_count(drop_x, drop_y, scale)
485
+
486
+ entries, y_step, y_start, y_end = self._mode_entries()
487
+ y = base_y + y_start * scale
488
+ show_counts = debug_enabled() and rl.is_key_down(rl.KeyboardKey.KEY_F1)
489
+
490
+ if show_counts:
491
+ draw_small_text(font, "times played:", base_x + 132.0 * scale, base_y + 16.0 * scale, text_scale, text_color)
492
+
493
+ for mode in entries:
494
+ self._draw_mode_button(mode, base_x, y, scale)
495
+ if show_counts and mode.show_count:
496
+ self._draw_mode_count(mode.key, base_x + 158.0 * scale, y + 8.0 * scale, text_scale, text_color)
497
+ y += y_step * scale
498
+
499
+ self._draw_tooltips(entries, base_x, base_y, y_end, scale)
500
+
501
+ def _draw_player_count(self, x: float, y: float, scale: float) -> None:
502
+ drop_on = self._drop_on
503
+ drop_off = self._drop_off
504
+ font = self._ensure_small_font()
505
+ layout = self._player_count_widget_layout(x, y, scale)
506
+ w = layout["w"]
507
+ header_h = layout["header_h"]
508
+ row_h = layout["row_h"]
509
+ rows_y0 = layout["rows_y0"]
510
+ full_h = layout["full_h"]
511
+ arrow_x = layout["arrow_x"]
512
+ arrow_y = layout["arrow_y"]
513
+ arrow_w = layout["arrow_w"]
514
+ arrow_h = layout["arrow_h"]
515
+ text_x = layout["text_x"]
516
+ text_y = layout["text_y"]
517
+ text_scale = layout["text_scale"]
518
+
519
+ # `ui_list_widget_update` draws a single bordered black rect for the widget.
520
+ widget_h = full_h if self._player_list_open else header_h
521
+ rl.draw_rectangle(int(x), int(y), int(w), int(widget_h), rl.BLACK)
522
+ rl.draw_rectangle_lines(int(x), int(y), int(w), int(widget_h), rl.WHITE)
523
+
524
+ # Arrow icon (the ui_drop* assets are 16x16 icons, not the background).
525
+ mouse = rl.get_mouse_position()
526
+ hovered_header = x <= mouse.x <= x + w and y <= mouse.y <= y + header_h
527
+ arrow_tex = drop_on if (self._player_list_open or hovered_header) else drop_off
528
+ if arrow_tex is not None:
529
+ rl.draw_texture_pro(
530
+ arrow_tex,
531
+ rl.Rectangle(0.0, 0.0, float(arrow_tex.width), float(arrow_tex.height)),
532
+ rl.Rectangle(arrow_x, arrow_y, arrow_w, arrow_h),
533
+ rl.Vector2(0.0, 0.0),
534
+ 0.0,
535
+ rl.WHITE,
536
+ )
537
+
538
+ player_count = int(self._state.config.data.get("player_count", 1))
539
+ if player_count < 1:
540
+ player_count = 1
541
+ if player_count > len(self._PLAYER_COUNT_LABELS):
542
+ player_count = len(self._PLAYER_COUNT_LABELS)
543
+ label = self._PLAYER_COUNT_LABELS[player_count - 1]
544
+ header_alpha = 191 if self._player_list_open else 242 # 0x3f400000 / 0x3f733333
545
+ draw_small_text(font, label, text_x, text_y, text_scale, rl.Color(255, 255, 255, header_alpha))
546
+
547
+ if not self._player_list_open:
548
+ return
549
+
550
+ for idx, item in enumerate(self._PLAYER_COUNT_LABELS):
551
+ item_y = rows_y0 + row_h * float(idx)
552
+ hovered = x <= mouse.x <= x + w and item_y <= mouse.y <= item_y + row_h
553
+ alpha = 179 # 0x3f333333
554
+ if hovered:
555
+ alpha = 242 # 0x3f733333
556
+ if idx == (player_count - 1):
557
+ alpha = max(alpha, 245) # 0x3f75c28f
558
+ draw_small_text(font, item, text_x, item_y, text_scale, rl.Color(255, 255, 255, alpha))
559
+
560
+ def _draw_mode_button(self, mode: _PlayGameModeEntry, x: float, y: float, scale: float) -> None:
561
+ tex = self._button_tex_for_label(mode.label, scale)
562
+ font = self._ensure_small_font()
563
+ rect = self._mode_button_rect(mode.label, x, y, scale)
564
+
565
+ mouse = rl.get_mouse_position()
566
+ hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
567
+ alpha = 255
568
+
569
+ if tex is not None:
570
+ rl.draw_texture_pro(
571
+ tex,
572
+ rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height)),
573
+ rect,
574
+ rl.Vector2(0.0, 0.0),
575
+ 0.0,
576
+ rl.Color(255, 255, 255, alpha),
577
+ )
578
+ else:
579
+ rl.draw_rectangle_lines(int(rect.x), int(rect.y), int(rect.width), int(rect.height), rl.Color(255, 255, 255, alpha))
580
+
581
+ label_w = measure_small_text_width(font, mode.label, 1.0 * scale)
582
+ # `ui_button_update` uses x centered (+1) and y = y + 10 (not fully centered).
583
+ text_x = rect.x + (rect.width - label_w) * 0.5 + 1.0 * scale
584
+ text_y = rect.y + 10.0 * scale
585
+ text_alpha = 255 if hovered else 179 # 0x3f800000 / 0x3f333333
586
+ draw_small_text(font, mode.label, text_x, text_y, 1.0 * scale, rl.Color(255, 255, 255, text_alpha))
587
+
588
+ def _draw_mode_count(self, key: str, x: float, y: float, scale: float, color: rl.Color) -> None:
589
+ status = self._state.status
590
+ if key == "quests":
591
+ count = self._quests_total_played()
592
+ elif key == "rush":
593
+ count = int(status.mode_play_count("rush"))
594
+ elif key == "survival":
595
+ count = int(status.mode_play_count("survival"))
596
+ elif key == "typo":
597
+ count = int(status.mode_play_count("typo"))
598
+ else:
599
+ return
600
+ draw_small_text(self._ensure_small_font(), f"{count}", x, y, scale, color)
601
+
602
+ def _draw_tooltips(self, entries: list[_PlayGameModeEntry], base_x: float, base_y: float, y_end: float, scale: float) -> None:
603
+ # `sub_44ed80` draws these below the mode list based on per-button hover timers.
604
+ font = self._ensure_small_font()
605
+ tooltip_x = base_x - 55.0 * scale
606
+ tooltip_y = base_y + (y_end + 16.0) * scale
607
+
608
+ offsets = {
609
+ "quests": (-8.0, 0.0),
610
+ "rush": (32.0, 0.0),
611
+ "survival": (20.0, 0.0),
612
+ "typo": (0.0, -12.0),
613
+ "tutorial": (38.0, 0.0),
614
+ }
615
+
616
+ for mode in entries:
617
+ ms = int(self._tooltip_ms.get(mode.key, 0))
618
+ if ms <= 0:
619
+ continue
620
+ alpha_f = min(1.0, float(ms) * 0.0009)
621
+ alpha = int(255 * alpha_f)
622
+ off_x, off_y = offsets.get(mode.key, (0.0, 0.0))
623
+ x = tooltip_x + off_x * scale
624
+ y = tooltip_y + off_y * scale
625
+ for line in mode.tooltip.splitlines():
626
+ draw_small_text(font, line, x, y, 1.0 * scale, rl.Color(255, 255, 255, alpha))
627
+ y += font.cell_size * 1.0 * scale