crimsonland 0.1.0.dev11__py3-none-any.whl → 0.1.0.dev12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ import math
5
+
6
+ import pyray as rl
7
+
8
+ from grim.audio import play_sfx, update_audio
9
+
10
+ from .assets import MenuAssets, load_menu_assets
11
+ from .menu import (
12
+ MENU_ITEM_OFFSET_X,
13
+ MENU_ITEM_OFFSET_Y,
14
+ MENU_LABEL_BASE_Y,
15
+ MENU_LABEL_HEIGHT,
16
+ MENU_LABEL_OFFSET_X,
17
+ MENU_LABEL_OFFSET_Y,
18
+ MENU_LABEL_ROW_BACK,
19
+ MENU_LABEL_ROW_HEIGHT,
20
+ MENU_LABEL_ROW_OPTIONS,
21
+ MENU_LABEL_ROW_QUIT,
22
+ MENU_LABEL_STEP,
23
+ MENU_LABEL_WIDTH,
24
+ MENU_SCALE_SMALL_THRESHOLD,
25
+ MENU_SIGN_HEIGHT,
26
+ MENU_SIGN_OFFSET_X,
27
+ MENU_SIGN_OFFSET_Y,
28
+ MENU_SIGN_POS_X_PAD,
29
+ MENU_SIGN_POS_Y,
30
+ MENU_SIGN_POS_Y_SMALL,
31
+ MENU_SIGN_WIDTH,
32
+ UI_SHADOW_OFFSET,
33
+ MenuEntry,
34
+ MenuView,
35
+ _draw_menu_cursor,
36
+ )
37
+ from .transitions import _draw_screen_fade
38
+
39
+ if TYPE_CHECKING:
40
+ from ..game import GameState, PauseBackground
41
+
42
+
43
+ class PauseMenuView:
44
+ def __init__(self, state: GameState) -> None:
45
+ self._state = state
46
+ self._assets: MenuAssets | None = None
47
+ self._menu_entries: list[MenuEntry] = []
48
+ self._selected_index = 0
49
+ self._focus_timer_ms = 0
50
+ self._hovered_index: int | None = None
51
+ self._timeline_ms = 0
52
+ self._timeline_max_ms = 0
53
+ self._cursor_pulse_time = 0.0
54
+ self._widescreen_y_shift = 0.0
55
+ self._menu_screen_width = 0
56
+ self._closing = False
57
+ self._close_action: str | None = None
58
+ self._pending_action: str | None = None
59
+ self._panel_open_sfx_played = False
60
+
61
+ def open(self) -> None:
62
+ layout_w = float(self._state.config.screen_width)
63
+ self._menu_screen_width = int(layout_w)
64
+ self._widescreen_y_shift = MenuView._menu_widescreen_y_shift(layout_w)
65
+ self._assets = load_menu_assets(self._state)
66
+
67
+ ys = [
68
+ MENU_LABEL_BASE_Y + self._widescreen_y_shift,
69
+ MENU_LABEL_BASE_Y + MENU_LABEL_STEP + self._widescreen_y_shift,
70
+ MENU_LABEL_BASE_Y + MENU_LABEL_STEP * 2.0 + self._widescreen_y_shift,
71
+ ]
72
+ self._menu_entries = [
73
+ MenuEntry(slot=0, row=MENU_LABEL_ROW_OPTIONS, y=ys[0]),
74
+ MenuEntry(slot=1, row=MENU_LABEL_ROW_QUIT, y=ys[1]),
75
+ MenuEntry(slot=2, row=MENU_LABEL_ROW_BACK, y=ys[2]),
76
+ ]
77
+ self._selected_index = 0 if self._menu_entries else -1
78
+ self._focus_timer_ms = 0
79
+ self._hovered_index = None
80
+ self._timeline_ms = 0
81
+ self._timeline_max_ms = max(300, *(MenuView._menu_slot_start_ms(entry.slot) for entry in self._menu_entries))
82
+ self._cursor_pulse_time = 0.0
83
+ self._closing = False
84
+ self._close_action = None
85
+ self._pending_action = None
86
+ self._panel_open_sfx_played = False
87
+
88
+ def close(self) -> None:
89
+ self._assets = None
90
+ self._menu_entries = []
91
+
92
+ def update(self, dt: float) -> None:
93
+ if self._state.audio is not None:
94
+ update_audio(self._state.audio, dt)
95
+ self._cursor_pulse_time += min(dt, 0.1) * 1.1
96
+
97
+ dt_ms = int(min(dt, 0.1) * 1000.0)
98
+ if self._closing:
99
+ if dt_ms > 0 and self._pending_action is None:
100
+ self._timeline_ms -= dt_ms
101
+ self._focus_timer_ms = max(0, self._focus_timer_ms - dt_ms)
102
+ if self._timeline_ms < 0 and self._close_action is not None:
103
+ self._pending_action = self._close_action
104
+ self._close_action = None
105
+ return
106
+
107
+ if dt_ms > 0:
108
+ self._timeline_ms = min(self._timeline_max_ms, self._timeline_ms + dt_ms)
109
+ self._focus_timer_ms = max(0, self._focus_timer_ms - dt_ms)
110
+ if self._timeline_ms >= self._timeline_max_ms:
111
+ self._state.menu_sign_locked = True
112
+ if (not self._panel_open_sfx_played) and (self._state.audio is not None):
113
+ play_sfx(self._state.audio, "sfx_ui_panelclick", rng=self._state.rng)
114
+ self._panel_open_sfx_played = True
115
+
116
+ if not self._menu_entries:
117
+ return
118
+
119
+ self._hovered_index = self._hovered_entry_index()
120
+
121
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
122
+ reverse = rl.is_key_down(rl.KeyboardKey.KEY_LEFT_SHIFT) or rl.is_key_down(rl.KeyboardKey.KEY_RIGHT_SHIFT)
123
+ delta = -1 if reverse else 1
124
+ self._selected_index = (self._selected_index + delta) % len(self._menu_entries)
125
+ self._focus_timer_ms = 1000
126
+
127
+ activated_index: int | None = None
128
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
129
+ # ESC behaves like selecting Back.
130
+ activated_index = self._entry_index_for_row(MENU_LABEL_ROW_BACK)
131
+ elif rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) and 0 <= self._selected_index < len(self._menu_entries):
132
+ entry = self._menu_entries[self._selected_index]
133
+ if self._menu_entry_enabled(entry):
134
+ activated_index = self._selected_index
135
+
136
+ if activated_index is None and self._hovered_index is not None:
137
+ if rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
138
+ hovered = self._hovered_index
139
+ entry = self._menu_entries[hovered]
140
+ if self._menu_entry_enabled(entry):
141
+ self._selected_index = hovered
142
+ self._focus_timer_ms = 1000
143
+ activated_index = hovered
144
+
145
+ if activated_index is not None:
146
+ self._activate_menu_entry(activated_index)
147
+
148
+ self._update_ready_timers(dt_ms)
149
+ self._update_hover_amounts(dt_ms)
150
+
151
+ def draw(self) -> None:
152
+ rl.clear_background(rl.BLACK)
153
+ pause_background = self._pause_background()
154
+ if pause_background is not None:
155
+ pause_background.draw_pause_background()
156
+ _draw_screen_fade(self._state)
157
+
158
+ assets = self._assets
159
+ if assets is None:
160
+ return
161
+ self._draw_menu_items()
162
+ self._draw_menu_sign()
163
+ _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
164
+
165
+ def take_action(self) -> str | None:
166
+ action = self._pending_action
167
+ self._pending_action = None
168
+ return action
169
+
170
+ def _pause_background(self) -> PauseBackground | None:
171
+ return self._state.pause_background
172
+
173
+ def _activate_menu_entry(self, index: int) -> None:
174
+ if not (0 <= index < len(self._menu_entries)):
175
+ return
176
+ entry = self._menu_entries[index]
177
+ action = self._action_for_entry(entry)
178
+ if action is None:
179
+ return
180
+ if self._state.audio is not None:
181
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
182
+ self._begin_close_transition(action)
183
+
184
+ @staticmethod
185
+ def _action_for_entry(entry: MenuEntry) -> str | None:
186
+ if entry.row == MENU_LABEL_ROW_OPTIONS:
187
+ return "open_options"
188
+ if entry.row == MENU_LABEL_ROW_QUIT:
189
+ return "back_to_menu"
190
+ if entry.row == MENU_LABEL_ROW_BACK:
191
+ return "back_to_previous"
192
+ return None
193
+
194
+ def _begin_close_transition(self, action: str) -> None:
195
+ if self._closing:
196
+ return
197
+ self._closing = True
198
+ self._close_action = action
199
+
200
+ def _menu_item_scale(self, slot: int) -> tuple[float, float]:
201
+ if self._menu_screen_width < (MENU_SCALE_SMALL_THRESHOLD + 1):
202
+ return 0.9, float(slot) * 11.0
203
+ return 1.0, 0.0
204
+
205
+ def _ui_element_anim(
206
+ self,
207
+ *,
208
+ index: int,
209
+ start_ms: int,
210
+ end_ms: int,
211
+ width: float,
212
+ ) -> tuple[float, float]:
213
+ # Matches ui_element_update: angle lerps pi/2 -> 0 over [end_ms, start_ms].
214
+ # Direction flag (element+0x314) appears to be 0 for menu elements.
215
+ if start_ms <= end_ms or width <= 0.0:
216
+ return 0.0, 0.0
217
+ t = self._timeline_ms
218
+ if t < end_ms:
219
+ angle = 1.5707964
220
+ offset_x = -abs(width)
221
+ elif t < start_ms:
222
+ elapsed = t - end_ms
223
+ span = float(start_ms - end_ms)
224
+ p = float(elapsed) / span
225
+ angle = 1.5707964 * (1.0 - p)
226
+ offset_x = -((1.0 - p) * abs(width))
227
+ else:
228
+ angle = 0.0
229
+ offset_x = 0.0
230
+ if index == 0:
231
+ angle = -abs(angle)
232
+ return angle, offset_x
233
+
234
+ def _menu_item_bounds(self, entry: MenuEntry) -> tuple[float, float, float, float]:
235
+ assets = self._assets
236
+ if assets is None or assets.item is None:
237
+ return (0.0, 0.0, 0.0, 0.0)
238
+ item_w = float(assets.item.width)
239
+ item_h = float(assets.item.height)
240
+ item_scale, local_y_shift = self._menu_item_scale(entry.slot)
241
+ x0 = MENU_ITEM_OFFSET_X * item_scale
242
+ y0 = MENU_ITEM_OFFSET_Y * item_scale - local_y_shift
243
+ x2 = (MENU_ITEM_OFFSET_X + item_w) * item_scale
244
+ y2 = (MENU_ITEM_OFFSET_Y + item_h) * item_scale - local_y_shift
245
+ w = x2 - x0
246
+ h = y2 - y0
247
+ pos_x = MenuView._menu_slot_pos_x(entry.slot)
248
+ pos_y = entry.y
249
+ left = pos_x + x0 + w * 0.54
250
+ top = pos_y + y0 + h * 0.28
251
+ right = pos_x + x2 - w * 0.05
252
+ bottom = pos_y + y2 - h * 0.10
253
+ return left, top, right, bottom
254
+
255
+ def _hovered_entry_index(self) -> int | None:
256
+ if not self._menu_entries:
257
+ return None
258
+ mouse = rl.get_mouse_position()
259
+ mouse_x = float(mouse.x)
260
+ mouse_y = float(mouse.y)
261
+ for idx, entry in enumerate(self._menu_entries):
262
+ if not self._menu_entry_enabled(entry):
263
+ continue
264
+ left, top, right, bottom = self._menu_item_bounds(entry)
265
+ if left <= mouse_x <= right and top <= mouse_y <= bottom:
266
+ return idx
267
+ return None
268
+
269
+ def _update_ready_timers(self, dt_ms: int) -> None:
270
+ for entry in self._menu_entries:
271
+ if entry.ready_timer_ms < 0x100:
272
+ entry.ready_timer_ms = min(0x100, entry.ready_timer_ms + dt_ms)
273
+
274
+ def _update_hover_amounts(self, dt_ms: int) -> None:
275
+ hovered_index = self._hovered_index
276
+ for idx, entry in enumerate(self._menu_entries):
277
+ hover = hovered_index is not None and idx == hovered_index
278
+ if hover:
279
+ entry.hover_amount += dt_ms * 6
280
+ else:
281
+ entry.hover_amount -= dt_ms * 2
282
+ entry.hover_amount = max(0, min(1000, entry.hover_amount))
283
+
284
+ def _menu_entry_enabled(self, entry: MenuEntry) -> bool:
285
+ return self._timeline_ms >= MenuView._menu_slot_start_ms(entry.slot)
286
+
287
+ def _draw_menu_items(self) -> None:
288
+ assets = self._assets
289
+ if assets is None or assets.labels is None or not self._menu_entries:
290
+ return
291
+ item = assets.item
292
+ if item is None:
293
+ return
294
+ label_tex = assets.labels
295
+ item_w = float(item.width)
296
+ item_h = float(item.height)
297
+ fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
298
+ for idx in range(len(self._menu_entries) - 1, -1, -1):
299
+ entry = self._menu_entries[idx]
300
+ pos_x = MenuView._menu_slot_pos_x(entry.slot)
301
+ pos_y = entry.y
302
+ angle_rad, slide_x = self._ui_element_anim(
303
+ index=entry.slot + 2,
304
+ start_ms=MenuView._menu_slot_start_ms(entry.slot),
305
+ end_ms=MenuView._menu_slot_end_ms(entry.slot),
306
+ width=item_w,
307
+ )
308
+ _ = slide_x # slide is ignored for render_mode==0 (transform) elements
309
+ item_scale, local_y_shift = self._menu_item_scale(entry.slot)
310
+ offset_x = MENU_ITEM_OFFSET_X * item_scale
311
+ offset_y = MENU_ITEM_OFFSET_Y * item_scale - local_y_shift
312
+ dst = rl.Rectangle(
313
+ pos_x,
314
+ pos_y,
315
+ item_w * item_scale,
316
+ item_h * item_scale,
317
+ )
318
+ origin = rl.Vector2(-offset_x, -offset_y)
319
+ rotation_deg = math.degrees(angle_rad)
320
+ if fx_detail:
321
+ MenuView._draw_ui_quad_shadow(
322
+ texture=item,
323
+ src=rl.Rectangle(0.0, 0.0, item_w, item_h),
324
+ dst=rl.Rectangle(dst.x + UI_SHADOW_OFFSET, dst.y + UI_SHADOW_OFFSET, dst.width, dst.height),
325
+ origin=origin,
326
+ rotation_deg=rotation_deg,
327
+ )
328
+ MenuView._draw_ui_quad(
329
+ texture=item,
330
+ src=rl.Rectangle(0.0, 0.0, item_w, item_h),
331
+ dst=dst,
332
+ origin=origin,
333
+ rotation_deg=rotation_deg,
334
+ tint=rl.WHITE,
335
+ )
336
+ counter_value = entry.hover_amount
337
+ if idx == self._selected_index and self._focus_timer_ms > 0:
338
+ counter_value = self._focus_timer_ms
339
+ alpha = MenuView._label_alpha(counter_value)
340
+ tint = rl.Color(255, 255, 255, alpha)
341
+ src = rl.Rectangle(
342
+ 0.0,
343
+ float(entry.row) * MENU_LABEL_ROW_HEIGHT,
344
+ MENU_LABEL_WIDTH,
345
+ MENU_LABEL_ROW_HEIGHT,
346
+ )
347
+ label_offset_x = MENU_LABEL_OFFSET_X * item_scale
348
+ label_offset_y = MENU_LABEL_OFFSET_Y * item_scale - local_y_shift
349
+ label_dst = rl.Rectangle(
350
+ pos_x,
351
+ pos_y,
352
+ MENU_LABEL_WIDTH * item_scale,
353
+ MENU_LABEL_HEIGHT * item_scale,
354
+ )
355
+ label_origin = rl.Vector2(-label_offset_x, -label_offset_y)
356
+ MenuView._draw_ui_quad(
357
+ texture=label_tex,
358
+ src=src,
359
+ dst=label_dst,
360
+ origin=label_origin,
361
+ rotation_deg=rotation_deg,
362
+ tint=tint,
363
+ )
364
+ if self._menu_entry_enabled(entry):
365
+ glow_alpha = alpha
366
+ if 0 <= entry.ready_timer_ms < 0x100:
367
+ glow_alpha = 0xFF - (entry.ready_timer_ms // 2)
368
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
369
+ MenuView._draw_ui_quad(
370
+ texture=label_tex,
371
+ src=src,
372
+ dst=label_dst,
373
+ origin=label_origin,
374
+ rotation_deg=rotation_deg,
375
+ tint=rl.Color(255, 255, 255, glow_alpha),
376
+ )
377
+ rl.end_blend_mode()
378
+
379
+ def _draw_menu_sign(self) -> None:
380
+ assets = self._assets
381
+ if assets is None or assets.sign is None:
382
+ return
383
+ screen_w = float(self._state.config.screen_width)
384
+ scale, shift_x = MenuView._sign_layout_scale(int(screen_w))
385
+ pos_x = screen_w + MENU_SIGN_POS_X_PAD
386
+ pos_y = MENU_SIGN_POS_Y if screen_w > MENU_SCALE_SMALL_THRESHOLD else MENU_SIGN_POS_Y_SMALL
387
+ sign_w = MENU_SIGN_WIDTH * scale
388
+ sign_h = MENU_SIGN_HEIGHT * scale
389
+ offset_x = MENU_SIGN_OFFSET_X * scale + shift_x
390
+ offset_y = MENU_SIGN_OFFSET_Y * scale
391
+ rotation_deg = 0.0
392
+ if not self._state.menu_sign_locked:
393
+ angle_rad, slide_x = self._ui_element_anim(
394
+ index=0,
395
+ start_ms=300,
396
+ end_ms=0,
397
+ width=sign_w,
398
+ )
399
+ _ = slide_x
400
+ rotation_deg = math.degrees(angle_rad)
401
+ sign = assets.sign
402
+ fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
403
+ if fx_detail:
404
+ MenuView._draw_ui_quad_shadow(
405
+ texture=sign,
406
+ src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
407
+ dst=rl.Rectangle(pos_x + UI_SHADOW_OFFSET, pos_y + UI_SHADOW_OFFSET, sign_w, sign_h),
408
+ origin=rl.Vector2(-offset_x, -offset_y),
409
+ rotation_deg=rotation_deg,
410
+ )
411
+ MenuView._draw_ui_quad(
412
+ texture=sign,
413
+ src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
414
+ dst=rl.Rectangle(pos_x, pos_y, sign_w, sign_h),
415
+ origin=rl.Vector2(-offset_x, -offset_y),
416
+ rotation_deg=rotation_deg,
417
+ tint=rl.WHITE,
418
+ )
419
+
420
+ def _entry_index_for_row(self, row: int) -> int | None:
421
+ for idx, entry in enumerate(self._menu_entries):
422
+ if entry.row == row:
423
+ return idx
424
+ return None
425
+