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
|
@@ -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
|