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.
- crimson/__init__.py +24 -0
- crimson/assets_fetch.py +60 -0
- crimson/atlas.py +92 -0
- crimson/audio_router.py +153 -0
- crimson/bonuses.py +167 -0
- crimson/camera.py +75 -0
- crimson/cli.py +377 -0
- crimson/creatures/__init__.py +8 -0
- crimson/creatures/ai.py +186 -0
- crimson/creatures/anim.py +173 -0
- crimson/creatures/damage.py +103 -0
- crimson/creatures/runtime.py +1019 -0
- crimson/creatures/spawn.py +2871 -0
- crimson/debug.py +7 -0
- crimson/demo.py +1360 -0
- crimson/demo_trial.py +140 -0
- crimson/effects.py +1086 -0
- crimson/effects_atlas.py +73 -0
- crimson/frontend/__init__.py +1 -0
- crimson/frontend/assets.py +43 -0
- crimson/frontend/boot.py +424 -0
- crimson/frontend/menu.py +700 -0
- crimson/frontend/panels/__init__.py +1 -0
- crimson/frontend/panels/base.py +410 -0
- crimson/frontend/panels/controls.py +132 -0
- crimson/frontend/panels/mods.py +128 -0
- crimson/frontend/panels/options.py +409 -0
- crimson/frontend/panels/play_game.py +627 -0
- crimson/frontend/panels/stats.py +351 -0
- crimson/frontend/transitions.py +31 -0
- crimson/game.py +2533 -0
- crimson/game_modes.py +15 -0
- crimson/game_world.py +663 -0
- crimson/gameplay.py +2450 -0
- crimson/input_codes.py +176 -0
- crimson/modes/__init__.py +1 -0
- crimson/modes/base_gameplay_mode.py +219 -0
- crimson/modes/quest_mode.py +502 -0
- crimson/modes/rush_mode.py +300 -0
- crimson/modes/survival_mode.py +792 -0
- crimson/modes/tutorial_mode.py +648 -0
- crimson/modes/typo_mode.py +472 -0
- crimson/paths.py +23 -0
- crimson/perks.py +828 -0
- crimson/persistence/__init__.py +1 -0
- crimson/persistence/highscores.py +385 -0
- crimson/persistence/save_status.py +245 -0
- crimson/player_damage.py +77 -0
- crimson/projectiles.py +1039 -0
- crimson/quests/__init__.py +18 -0
- crimson/quests/helpers.py +147 -0
- crimson/quests/registry.py +49 -0
- crimson/quests/results.py +164 -0
- crimson/quests/runtime.py +91 -0
- crimson/quests/tier1.py +620 -0
- crimson/quests/tier2.py +652 -0
- crimson/quests/tier3.py +579 -0
- crimson/quests/tier4.py +721 -0
- crimson/quests/tier5.py +886 -0
- crimson/quests/timeline.py +115 -0
- crimson/quests/types.py +70 -0
- crimson/render/__init__.py +1 -0
- crimson/render/terrain_fx.py +88 -0
- crimson/render/world_renderer.py +1338 -0
- crimson/sim/__init__.py +1 -0
- crimson/sim/world_defs.py +56 -0
- crimson/sim/world_state.py +421 -0
- crimson/terrain_assets.py +19 -0
- crimson/tutorial/__init__.py +12 -0
- crimson/tutorial/timeline.py +291 -0
- crimson/typo/__init__.py +2 -0
- crimson/typo/names.py +233 -0
- crimson/typo/player.py +43 -0
- crimson/typo/spawns.py +73 -0
- crimson/typo/typing.py +52 -0
- crimson/ui/__init__.py +3 -0
- crimson/ui/cursor.py +95 -0
- crimson/ui/demo_trial_overlay.py +235 -0
- crimson/ui/game_over.py +660 -0
- crimson/ui/hud.py +601 -0
- crimson/ui/perk_menu.py +388 -0
- crimson/views/__init__.py +40 -0
- crimson/views/aim_debug.py +276 -0
- crimson/views/animations.py +274 -0
- crimson/views/arsenal_debug.py +414 -0
- crimson/views/bonuses.py +201 -0
- crimson/views/camera_debug.py +359 -0
- crimson/views/camera_shake.py +229 -0
- crimson/views/corpse_stamp_debug.py +324 -0
- crimson/views/decals_debug.py +739 -0
- crimson/views/empty.py +19 -0
- crimson/views/fonts.py +114 -0
- crimson/views/game_over.py +117 -0
- crimson/views/ground.py +259 -0
- crimson/views/lighting_debug.py +1166 -0
- crimson/views/particles.py +293 -0
- crimson/views/perk_menu_debug.py +430 -0
- crimson/views/perks.py +398 -0
- crimson/views/player.py +433 -0
- crimson/views/player_sprite_debug.py +314 -0
- crimson/views/projectile_fx.py +608 -0
- crimson/views/projectile_render_debug.py +407 -0
- crimson/views/projectiles.py +221 -0
- crimson/views/quest_title_overlay.py +108 -0
- crimson/views/registry.py +34 -0
- crimson/views/rush.py +16 -0
- crimson/views/small_font_debug.py +204 -0
- crimson/views/spawn_plan.py +363 -0
- crimson/views/sprites.py +214 -0
- crimson/views/survival.py +15 -0
- crimson/views/terrain.py +132 -0
- crimson/views/ui.py +123 -0
- crimson/views/wicons.py +166 -0
- crimson/weapon_sfx.py +63 -0
- crimson/weapons.py +860 -0
- crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
- crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
- crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
- crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
- grim/__init__.py +20 -0
- grim/app.py +92 -0
- grim/assets.py +231 -0
- grim/audio.py +106 -0
- grim/config.py +294 -0
- grim/console.py +737 -0
- grim/fonts/__init__.py +7 -0
- grim/fonts/grim_mono.py +111 -0
- grim/fonts/small.py +120 -0
- grim/input.py +44 -0
- grim/jaz.py +103 -0
- grim/math.py +17 -0
- grim/music.py +403 -0
- grim/paq.py +76 -0
- grim/rand.py +37 -0
- grim/sfx.py +276 -0
- grim/sfx_map.py +103 -0
- grim/terrain_render.py +840 -0
- grim/view.py +16 -0
crimson/frontend/menu.py
ADDED
|
@@ -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
|