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.
- crimson/__init__.py +24 -0
- crimson/assets_fetch.py +60 -0
- crimson/atlas.py +92 -0
- crimson/audio_router.py +155 -0
- crimson/bonuses.py +167 -0
- crimson/camera.py +75 -0
- crimson/cli.py +380 -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 +652 -0
- crimson/gameplay.py +2467 -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 +1133 -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 +1941 -0
- crimson/sim/__init__.py +1 -0
- crimson/sim/world_defs.py +67 -0
- crimson/sim/world_state.py +422 -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 +404 -0
- crimson/views/audio_bootstrap.py +47 -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 +434 -0
- crimson/views/player_sprite_debug.py +314 -0
- crimson/views/projectile_fx.py +609 -0
- crimson/views/projectile_render_debug.py +393 -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.dev5.dist-info/METADATA +9 -0
- crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
- crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
- crimsonland-0.1.0.dev5.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/game.py
ADDED
|
@@ -0,0 +1,2533 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import faulthandler
|
|
7
|
+
import math
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
import webbrowser
|
|
12
|
+
from typing import Protocol, TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import pyray as rl
|
|
15
|
+
|
|
16
|
+
from grim.audio import (
|
|
17
|
+
AudioState,
|
|
18
|
+
play_music,
|
|
19
|
+
play_sfx,
|
|
20
|
+
stop_music,
|
|
21
|
+
update_audio,
|
|
22
|
+
)
|
|
23
|
+
from grim.assets import (
|
|
24
|
+
LogoAssets,
|
|
25
|
+
PaqTextureCache,
|
|
26
|
+
load_paq_entries_from_path,
|
|
27
|
+
)
|
|
28
|
+
from grim.config import CrimsonConfig, ensure_crimson_cfg
|
|
29
|
+
from grim.console import (
|
|
30
|
+
CommandHandler,
|
|
31
|
+
ConsoleState,
|
|
32
|
+
create_console,
|
|
33
|
+
register_boot_commands,
|
|
34
|
+
register_core_cvars,
|
|
35
|
+
)
|
|
36
|
+
from grim.app import run_view
|
|
37
|
+
from grim.terrain_render import GroundRenderer
|
|
38
|
+
from grim.view import View, ViewContext
|
|
39
|
+
from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
|
|
40
|
+
|
|
41
|
+
from .debug import debug_enabled
|
|
42
|
+
from grim import music
|
|
43
|
+
|
|
44
|
+
from .demo import DemoView
|
|
45
|
+
from .demo_trial import (
|
|
46
|
+
DEMO_QUEST_GRACE_TIME_MS,
|
|
47
|
+
DEMO_TOTAL_PLAY_TIME_MS,
|
|
48
|
+
demo_trial_overlay_info,
|
|
49
|
+
format_demo_trial_time,
|
|
50
|
+
tick_demo_trial_timers,
|
|
51
|
+
)
|
|
52
|
+
from .frontend.boot import BootView
|
|
53
|
+
from .frontend.assets import MenuAssets, _ensure_texture_cache, load_menu_assets
|
|
54
|
+
from .frontend.menu import (
|
|
55
|
+
MENU_PANEL_HEIGHT,
|
|
56
|
+
MENU_PANEL_OFFSET_X,
|
|
57
|
+
MENU_PANEL_OFFSET_Y,
|
|
58
|
+
MENU_PANEL_WIDTH,
|
|
59
|
+
MENU_SCALE_SMALL_THRESHOLD,
|
|
60
|
+
MENU_SIGN_HEIGHT,
|
|
61
|
+
MENU_SIGN_OFFSET_X,
|
|
62
|
+
MENU_SIGN_OFFSET_Y,
|
|
63
|
+
MENU_SIGN_POS_X_PAD,
|
|
64
|
+
MENU_SIGN_POS_Y,
|
|
65
|
+
MENU_SIGN_POS_Y_SMALL,
|
|
66
|
+
MENU_SIGN_WIDTH,
|
|
67
|
+
UI_SHADOW_OFFSET,
|
|
68
|
+
UI_SHADOW_TINT,
|
|
69
|
+
MenuView,
|
|
70
|
+
_draw_menu_cursor,
|
|
71
|
+
ensure_menu_ground,
|
|
72
|
+
)
|
|
73
|
+
from .frontend.panels.base import PANEL_TIMELINE_END_MS, PANEL_TIMELINE_START_MS, PanelMenuView
|
|
74
|
+
from .frontend.panels.controls import ControlsMenuView
|
|
75
|
+
from .frontend.panels.mods import ModsMenuView
|
|
76
|
+
from .frontend.panels.options import OptionsMenuView
|
|
77
|
+
from .frontend.panels.play_game import PlayGameMenuView
|
|
78
|
+
from .frontend.panels.stats import StatisticsMenuView
|
|
79
|
+
from .frontend.transitions import _draw_screen_fade, _update_screen_fade
|
|
80
|
+
from .persistence.save_status import GameStatus, ensure_game_status
|
|
81
|
+
from .ui.demo_trial_overlay import DEMO_PURCHASE_URL, DemoTrialOverlayUi
|
|
82
|
+
from .paths import default_runtime_dir
|
|
83
|
+
from .assets_fetch import download_missing_paqs
|
|
84
|
+
|
|
85
|
+
if TYPE_CHECKING:
|
|
86
|
+
from .modes.quest_mode import QuestRunOutcome
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class GameConfig:
|
|
90
|
+
base_dir: Path = field(default_factory=default_runtime_dir)
|
|
91
|
+
assets_dir: Path | None = None
|
|
92
|
+
width: int | None = None
|
|
93
|
+
height: int | None = None
|
|
94
|
+
fps: int = 60
|
|
95
|
+
seed: int | None = None
|
|
96
|
+
demo_enabled: bool = False
|
|
97
|
+
no_intro: bool = False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(slots=True)
|
|
101
|
+
class HighScoresRequest:
|
|
102
|
+
game_mode_id: int
|
|
103
|
+
quest_stage_major: int = 0
|
|
104
|
+
quest_stage_minor: int = 0
|
|
105
|
+
highlight_rank: int | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class GameState:
|
|
110
|
+
base_dir: Path
|
|
111
|
+
assets_dir: Path
|
|
112
|
+
rng: random.Random
|
|
113
|
+
config: CrimsonConfig
|
|
114
|
+
status: GameStatus
|
|
115
|
+
console: ConsoleState
|
|
116
|
+
demo_enabled: bool
|
|
117
|
+
logos: LogoAssets | None
|
|
118
|
+
texture_cache: PaqTextureCache | None
|
|
119
|
+
audio: AudioState | None
|
|
120
|
+
resource_paq: Path
|
|
121
|
+
session_start: float
|
|
122
|
+
skip_intro: bool = False
|
|
123
|
+
gamma_ramp: float = 1.0
|
|
124
|
+
snd_freq_adjustment_enabled: bool = False
|
|
125
|
+
menu_ground: GroundRenderer | None = None
|
|
126
|
+
menu_sign_locked: bool = False
|
|
127
|
+
pending_quest_level: str | None = None
|
|
128
|
+
pending_high_scores: HighScoresRequest | None = None
|
|
129
|
+
quest_outcome: QuestRunOutcome | None = None
|
|
130
|
+
quest_fail_retry_count: int = 0
|
|
131
|
+
demo_trial_elapsed_ms: int = 0
|
|
132
|
+
quit_requested: bool = False
|
|
133
|
+
screen_fade_alpha: float = 0.0
|
|
134
|
+
screen_fade_ramp: bool = False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
CRIMSON_PAQ_NAME = "crimson.paq"
|
|
138
|
+
MUSIC_PAQ_NAME = "music.paq"
|
|
139
|
+
SFX_PAQ_NAME = "sfx.paq"
|
|
140
|
+
AUTOEXEC_NAME = "autoexec.txt"
|
|
141
|
+
|
|
142
|
+
QUEST_MENU_BASE_X = -5.0
|
|
143
|
+
QUEST_MENU_BASE_Y = 185.0
|
|
144
|
+
|
|
145
|
+
QUEST_TITLE_X_OFFSET = 219.0 # 300 + 64 - 145
|
|
146
|
+
QUEST_TITLE_Y_OFFSET = 44.0 # 40 + 4
|
|
147
|
+
QUEST_TITLE_W = 64.0
|
|
148
|
+
QUEST_TITLE_H = 32.0
|
|
149
|
+
|
|
150
|
+
QUEST_STAGE_ICON_X_OFFSET = 80.0 # 64 + 16
|
|
151
|
+
QUEST_STAGE_ICON_Y_OFFSET = 3.0
|
|
152
|
+
QUEST_STAGE_ICON_SIZE = 32.0
|
|
153
|
+
QUEST_STAGE_ICON_STEP = 36.0
|
|
154
|
+
QUEST_STAGE_ICON_SCALE_UNSELECTED = 0.8
|
|
155
|
+
|
|
156
|
+
QUEST_LIST_Y_OFFSET = 50.0
|
|
157
|
+
QUEST_LIST_ROW_STEP = 20.0
|
|
158
|
+
QUEST_LIST_NAME_X_OFFSET = 32.0
|
|
159
|
+
QUEST_LIST_HOVER_LEFT_PAD = 10.0
|
|
160
|
+
QUEST_LIST_HOVER_RIGHT_PAD = 210.0
|
|
161
|
+
QUEST_LIST_HOVER_TOP_PAD = 2.0
|
|
162
|
+
QUEST_LIST_HOVER_BOTTOM_PAD = 18.0
|
|
163
|
+
|
|
164
|
+
QUEST_HARDCORE_UNLOCK_INDEX = 40
|
|
165
|
+
QUEST_HARDCORE_CHECKBOX_X_OFFSET = 132.0
|
|
166
|
+
QUEST_HARDCORE_CHECKBOX_Y_OFFSET = -12.0
|
|
167
|
+
QUEST_HARDCORE_LIST_Y_SHIFT = 10.0
|
|
168
|
+
|
|
169
|
+
QUEST_BACK_BUTTON_X_OFFSET = 148.0
|
|
170
|
+
QUEST_BACK_BUTTON_Y_OFFSET = 212.0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class QuestsMenuView:
|
|
174
|
+
"""Quest selection menu.
|
|
175
|
+
|
|
176
|
+
Layout and gating are based on `sub_447d40` (crimsonland.exe).
|
|
177
|
+
|
|
178
|
+
The classic game treats this as a distinct UI state (transition target `0x0b`),
|
|
179
|
+
entered from the Play Game panel.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, state: GameState) -> None:
|
|
183
|
+
self._state = state
|
|
184
|
+
self._assets: MenuAssets | None = None
|
|
185
|
+
self._ground: GroundRenderer | None = None
|
|
186
|
+
self._panel_tex: rl.Texture2D | None = None
|
|
187
|
+
|
|
188
|
+
self._small_font: SmallFontData | None = None
|
|
189
|
+
self._text_quest: rl.Texture2D | None = None
|
|
190
|
+
self._stage_icons: dict[int, rl.Texture2D | None] = {}
|
|
191
|
+
self._check_on: rl.Texture2D | None = None
|
|
192
|
+
self._check_off: rl.Texture2D | None = None
|
|
193
|
+
self._button_sm: rl.Texture2D | None = None
|
|
194
|
+
self._button_md: rl.Texture2D | None = None
|
|
195
|
+
|
|
196
|
+
self._menu_screen_width = 0
|
|
197
|
+
self._widescreen_y_shift = 0.0
|
|
198
|
+
|
|
199
|
+
self._stage = 1
|
|
200
|
+
self._action: str | None = None
|
|
201
|
+
self._dirty = False
|
|
202
|
+
self._cursor_pulse_time = 0.0
|
|
203
|
+
|
|
204
|
+
def open(self) -> None:
|
|
205
|
+
layout_w = float(self._state.config.screen_width)
|
|
206
|
+
self._menu_screen_width = int(layout_w)
|
|
207
|
+
self._widescreen_y_shift = MenuView._menu_widescreen_y_shift(layout_w)
|
|
208
|
+
cache = _ensure_texture_cache(self._state)
|
|
209
|
+
|
|
210
|
+
# Sign and ground match the main menu/panels.
|
|
211
|
+
self._assets = load_menu_assets(self._state)
|
|
212
|
+
self._panel_tex = self._assets.panel if self._assets is not None else None
|
|
213
|
+
self._init_ground()
|
|
214
|
+
|
|
215
|
+
self._text_quest = cache.get_or_load("ui_textQuest", "ui/ui_textQuest.jaz").texture
|
|
216
|
+
self._stage_icons = {
|
|
217
|
+
1: cache.get_or_load("ui_num1", "ui/ui_num1.jaz").texture,
|
|
218
|
+
2: cache.get_or_load("ui_num2", "ui/ui_num2.jaz").texture,
|
|
219
|
+
3: cache.get_or_load("ui_num3", "ui/ui_num3.jaz").texture,
|
|
220
|
+
4: cache.get_or_load("ui_num4", "ui/ui_num4.jaz").texture,
|
|
221
|
+
5: cache.get_or_load("ui_num5", "ui/ui_num5.jaz").texture,
|
|
222
|
+
}
|
|
223
|
+
self._check_on = cache.get_or_load("ui_checkOn", "ui/ui_checkOn.jaz").texture
|
|
224
|
+
self._check_off = cache.get_or_load("ui_checkOff", "ui/ui_checkOff.jaz").texture
|
|
225
|
+
self._button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
|
|
226
|
+
self._button_md = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
|
|
227
|
+
|
|
228
|
+
self._action = None
|
|
229
|
+
self._dirty = False
|
|
230
|
+
self._stage = max(1, min(5, int(self._stage)))
|
|
231
|
+
self._cursor_pulse_time = 0.0
|
|
232
|
+
|
|
233
|
+
# Ensure the quest registry is populated so titles render.
|
|
234
|
+
# (The package import registers all tier builders.)
|
|
235
|
+
try:
|
|
236
|
+
from . import quests as _quests
|
|
237
|
+
|
|
238
|
+
_ = _quests
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
def close(self) -> None:
|
|
243
|
+
if self._dirty:
|
|
244
|
+
try:
|
|
245
|
+
self._state.config.save()
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
self._dirty = False
|
|
249
|
+
self._ground = None
|
|
250
|
+
|
|
251
|
+
def update(self, dt: float) -> None:
|
|
252
|
+
if self._state.audio is not None:
|
|
253
|
+
update_audio(self._state.audio, dt)
|
|
254
|
+
if self._ground is not None:
|
|
255
|
+
self._ground.process_pending()
|
|
256
|
+
self._cursor_pulse_time += min(dt, 0.1) * 1.1
|
|
257
|
+
|
|
258
|
+
config = self._state.config
|
|
259
|
+
|
|
260
|
+
# The original forcibly clears hardcore in the demo build.
|
|
261
|
+
if self._state.demo_enabled:
|
|
262
|
+
if int(config.data.get("hardcore_flag", 0) or 0) != 0:
|
|
263
|
+
config.data["hardcore_flag"] = 0
|
|
264
|
+
self._dirty = True
|
|
265
|
+
|
|
266
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
267
|
+
self._action = "open_play_game"
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
|
|
271
|
+
self._stage = max(1, self._stage - 1)
|
|
272
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
|
|
273
|
+
self._stage = min(5, self._stage + 1)
|
|
274
|
+
|
|
275
|
+
layout = self._layout()
|
|
276
|
+
|
|
277
|
+
# Stage icons: hover is tracked, but stage selection requires a click.
|
|
278
|
+
hovered_stage = self._hovered_stage(layout)
|
|
279
|
+
if hovered_stage is not None and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
|
|
280
|
+
self._stage = hovered_stage
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
if self._hardcore_checkbox_clicked(layout):
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if self._back_button_clicked(layout):
|
|
287
|
+
self._action = "open_play_game"
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
# Quick-select row numbers 1..0 (10).
|
|
291
|
+
row_from_key = self._digit_row_pressed()
|
|
292
|
+
if row_from_key is not None:
|
|
293
|
+
self._try_start_quest(self._stage, row_from_key)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
hovered_row = self._hovered_row(layout)
|
|
297
|
+
if hovered_row is not None and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
|
|
298
|
+
self._try_start_quest(self._stage, hovered_row)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
if hovered_row is not None and rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
|
|
302
|
+
self._try_start_quest(self._stage, hovered_row)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
def draw(self) -> None:
|
|
306
|
+
rl.clear_background(rl.BLACK)
|
|
307
|
+
if self._ground is not None:
|
|
308
|
+
self._ground.draw(0.0, 0.0)
|
|
309
|
+
|
|
310
|
+
self._draw_panel()
|
|
311
|
+
self._draw_sign()
|
|
312
|
+
self._draw_contents()
|
|
313
|
+
_draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
|
|
314
|
+
|
|
315
|
+
def take_action(self) -> str | None:
|
|
316
|
+
action = self._action
|
|
317
|
+
self._action = None
|
|
318
|
+
return action
|
|
319
|
+
|
|
320
|
+
def _ensure_small_font(self) -> SmallFontData:
|
|
321
|
+
if self._small_font is not None:
|
|
322
|
+
return self._small_font
|
|
323
|
+
missing_assets: list[str] = []
|
|
324
|
+
self._small_font = load_small_font(self._state.assets_dir, missing_assets)
|
|
325
|
+
return self._small_font
|
|
326
|
+
|
|
327
|
+
def _init_ground(self) -> None:
|
|
328
|
+
self._ground = ensure_menu_ground(self._state)
|
|
329
|
+
|
|
330
|
+
def _layout(self) -> dict[str, float]:
|
|
331
|
+
# `sub_447d40` base sums:
|
|
332
|
+
# x_sum = <ui_element_x> + (-5)
|
|
333
|
+
# y_sum = <ui_element_y> + 185 (+ widescreen shift via ui_menu_layout_init)
|
|
334
|
+
x_sum = QUEST_MENU_BASE_X
|
|
335
|
+
y_sum = QUEST_MENU_BASE_Y + self._widescreen_y_shift
|
|
336
|
+
|
|
337
|
+
title_x = x_sum + QUEST_TITLE_X_OFFSET
|
|
338
|
+
title_y = y_sum + QUEST_TITLE_Y_OFFSET
|
|
339
|
+
icons_x0 = title_x + QUEST_STAGE_ICON_X_OFFSET
|
|
340
|
+
icons_y = title_y + QUEST_STAGE_ICON_Y_OFFSET
|
|
341
|
+
last_icon_x = icons_x0 + QUEST_STAGE_ICON_STEP * 4.0
|
|
342
|
+
list_x = last_icon_x - 208.0 + 16.0
|
|
343
|
+
list_y0 = title_y + QUEST_LIST_Y_OFFSET
|
|
344
|
+
return {
|
|
345
|
+
"title_x": title_x,
|
|
346
|
+
"title_y": title_y,
|
|
347
|
+
"icons_x0": icons_x0,
|
|
348
|
+
"icons_y": icons_y,
|
|
349
|
+
"list_x": list_x,
|
|
350
|
+
"list_y0": list_y0,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
def _hovered_stage(self, layout: dict[str, float]) -> int | None:
|
|
354
|
+
title_y = layout["title_y"]
|
|
355
|
+
x0 = layout["icons_x0"]
|
|
356
|
+
mouse = rl.get_mouse_position()
|
|
357
|
+
for stage in range(1, 6):
|
|
358
|
+
x = x0 + float(stage - 1) * QUEST_STAGE_ICON_STEP
|
|
359
|
+
# Hover bounds are fixed 32x32, anchored at (x, title_y) (not icons_y).
|
|
360
|
+
if (x <= mouse.x <= x + QUEST_STAGE_ICON_SIZE) and (title_y <= mouse.y <= title_y + QUEST_STAGE_ICON_SIZE):
|
|
361
|
+
return stage
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
def _hardcore_checkbox_clicked(self, layout: dict[str, float]) -> bool:
|
|
365
|
+
status = self._state.status
|
|
366
|
+
if int(status.quest_unlock_index) < QUEST_HARDCORE_UNLOCK_INDEX:
|
|
367
|
+
return False
|
|
368
|
+
check_on = self._check_on
|
|
369
|
+
check_off = self._check_off
|
|
370
|
+
if check_on is None or check_off is None:
|
|
371
|
+
return False
|
|
372
|
+
config = self._state.config
|
|
373
|
+
hardcore = bool(int(config.data.get("hardcore_flag", 0) or 0))
|
|
374
|
+
|
|
375
|
+
font = self._ensure_small_font()
|
|
376
|
+
text_scale = 1.0
|
|
377
|
+
label = "Hardcore"
|
|
378
|
+
label_w = measure_small_text_width(font, label, text_scale)
|
|
379
|
+
|
|
380
|
+
x = layout["list_x"] + QUEST_HARDCORE_CHECKBOX_X_OFFSET
|
|
381
|
+
y = layout["list_y0"] + QUEST_HARDCORE_CHECKBOX_Y_OFFSET
|
|
382
|
+
rect_w = float(check_on.width) + 6.0 + label_w
|
|
383
|
+
rect_h = max(float(check_on.height), font.cell_size * text_scale)
|
|
384
|
+
|
|
385
|
+
mouse = rl.get_mouse_position()
|
|
386
|
+
hovered = x <= mouse.x <= x + rect_w and y <= mouse.y <= y + rect_h
|
|
387
|
+
if hovered and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
|
|
388
|
+
config.data["hardcore_flag"] = 0 if hardcore else 1
|
|
389
|
+
self._dirty = True
|
|
390
|
+
if self._state.demo_enabled:
|
|
391
|
+
config.data["hardcore_flag"] = 0
|
|
392
|
+
return True
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
def _back_button_clicked(self, layout: dict[str, float]) -> bool:
|
|
396
|
+
tex = self._button_sm
|
|
397
|
+
if tex is None:
|
|
398
|
+
tex = self._button_md
|
|
399
|
+
if tex is None:
|
|
400
|
+
return False
|
|
401
|
+
x = layout["list_x"] + QUEST_BACK_BUTTON_X_OFFSET
|
|
402
|
+
y = self._rows_y0(layout) + QUEST_BACK_BUTTON_Y_OFFSET
|
|
403
|
+
w = float(tex.width)
|
|
404
|
+
h = float(tex.height)
|
|
405
|
+
mouse = rl.get_mouse_position()
|
|
406
|
+
hovered = x <= mouse.x <= x + w and y <= mouse.y <= y + h
|
|
407
|
+
return hovered and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _digit_row_pressed() -> int | None:
|
|
411
|
+
keys = [
|
|
412
|
+
(rl.KeyboardKey.KEY_ONE, 0),
|
|
413
|
+
(rl.KeyboardKey.KEY_TWO, 1),
|
|
414
|
+
(rl.KeyboardKey.KEY_THREE, 2),
|
|
415
|
+
(rl.KeyboardKey.KEY_FOUR, 3),
|
|
416
|
+
(rl.KeyboardKey.KEY_FIVE, 4),
|
|
417
|
+
(rl.KeyboardKey.KEY_SIX, 5),
|
|
418
|
+
(rl.KeyboardKey.KEY_SEVEN, 6),
|
|
419
|
+
(rl.KeyboardKey.KEY_EIGHT, 7),
|
|
420
|
+
(rl.KeyboardKey.KEY_NINE, 8),
|
|
421
|
+
(rl.KeyboardKey.KEY_ZERO, 9),
|
|
422
|
+
]
|
|
423
|
+
for key, row in keys:
|
|
424
|
+
if rl.is_key_pressed(key):
|
|
425
|
+
return row
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
def _rows_y0(self, layout: dict[str, float]) -> float:
|
|
429
|
+
# `sub_447d40` adds +10 to the list Y after rendering the Hardcore checkbox.
|
|
430
|
+
status = self._state.status
|
|
431
|
+
y0 = layout["list_y0"]
|
|
432
|
+
if int(status.quest_unlock_index) >= QUEST_HARDCORE_UNLOCK_INDEX:
|
|
433
|
+
y0 += QUEST_HARDCORE_LIST_Y_SHIFT
|
|
434
|
+
return y0
|
|
435
|
+
|
|
436
|
+
def _hovered_row(self, layout: dict[str, float]) -> int | None:
|
|
437
|
+
list_x = layout["list_x"]
|
|
438
|
+
y0 = self._rows_y0(layout)
|
|
439
|
+
mouse = rl.get_mouse_position()
|
|
440
|
+
for row in range(10):
|
|
441
|
+
y = y0 + float(row) * QUEST_LIST_ROW_STEP
|
|
442
|
+
left = list_x - QUEST_LIST_HOVER_LEFT_PAD
|
|
443
|
+
top = y - QUEST_LIST_HOVER_TOP_PAD
|
|
444
|
+
right = list_x + QUEST_LIST_HOVER_RIGHT_PAD
|
|
445
|
+
bottom = y + QUEST_LIST_HOVER_BOTTOM_PAD
|
|
446
|
+
if left <= mouse.x <= right and top <= mouse.y <= bottom:
|
|
447
|
+
return row
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
def _quest_unlocked(self, stage: int, row: int) -> bool:
|
|
451
|
+
status = self._state.status
|
|
452
|
+
config = self._state.config
|
|
453
|
+
unlock = int(status.quest_unlock_index)
|
|
454
|
+
if bool(int(config.data.get("hardcore_flag", 0) or 0)):
|
|
455
|
+
unlock = int(status.quest_unlock_index_full)
|
|
456
|
+
global_index = (int(stage) - 1) * 10 + int(row)
|
|
457
|
+
return unlock >= global_index
|
|
458
|
+
|
|
459
|
+
def _try_start_quest(self, stage: int, row: int) -> None:
|
|
460
|
+
if not self._quest_unlocked(stage, row):
|
|
461
|
+
return
|
|
462
|
+
level = f"{int(stage)}.{int(row) + 1}"
|
|
463
|
+
self._state.pending_quest_level = level
|
|
464
|
+
self._state.config.data["game_mode"] = 3
|
|
465
|
+
self._dirty = True
|
|
466
|
+
self._action = "start_quest"
|
|
467
|
+
|
|
468
|
+
def _quest_title(self, stage: int, row: int) -> str:
|
|
469
|
+
level = f"{int(stage)}.{int(row) + 1}"
|
|
470
|
+
try:
|
|
471
|
+
from .quests import quest_by_level
|
|
472
|
+
|
|
473
|
+
quest = quest_by_level(level)
|
|
474
|
+
except Exception:
|
|
475
|
+
quest = None
|
|
476
|
+
if quest is None:
|
|
477
|
+
return "???"
|
|
478
|
+
return quest.title
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _quest_row_colors(*, hardcore: bool) -> tuple[rl.Color, rl.Color]:
|
|
482
|
+
# `sub_447d40` uses different RGB when hardcore is toggled.
|
|
483
|
+
if hardcore:
|
|
484
|
+
# (0.980392, 0.274509, 0.235294, alpha)
|
|
485
|
+
r, g, b = 250, 70, 60
|
|
486
|
+
else:
|
|
487
|
+
# (0.274509, 0.707..., 0.941..., alpha)
|
|
488
|
+
r, g, b = 70, 180, 240
|
|
489
|
+
return (rl.Color(r, g, b, 153), rl.Color(r, g, b, 255))
|
|
490
|
+
|
|
491
|
+
def _quest_counts(self, *, stage: int, row: int) -> tuple[int, int] | None:
|
|
492
|
+
# In `sub_447d40`, counts are indexed by (row + stage*10) and split across two
|
|
493
|
+
# arrays at offsets 0xDC (games) and 0x17C (completed) within game.cfg.
|
|
494
|
+
#
|
|
495
|
+
# Stage 5 does not fit cleanly in the saved blob:
|
|
496
|
+
# - The "games" index range would overlap stage-1 completion counters.
|
|
497
|
+
# - The "completed" index range reads into trailing fields (mode counters,
|
|
498
|
+
# game_sequence_id, and unknown tail bytes), and the last row would run past
|
|
499
|
+
# the decoded payload.
|
|
500
|
+
#
|
|
501
|
+
# We emulate this layout so the debug `F1` overlay matches the classic build.
|
|
502
|
+
global_index = (int(stage) - 1) * 10 + int(row)
|
|
503
|
+
if not (0 <= global_index < 50):
|
|
504
|
+
return None
|
|
505
|
+
count_index = global_index + 10
|
|
506
|
+
|
|
507
|
+
status = self._state.status
|
|
508
|
+
games_idx = 1 + count_index
|
|
509
|
+
completed_idx = 41 + count_index
|
|
510
|
+
try:
|
|
511
|
+
games = int(status.quest_play_count(games_idx))
|
|
512
|
+
except Exception:
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
completed = int(status.quest_play_count(completed_idx))
|
|
517
|
+
except Exception:
|
|
518
|
+
# Stage-5 completed reads into trailing fields (and beyond).
|
|
519
|
+
if int(stage) != 5:
|
|
520
|
+
return None
|
|
521
|
+
tail_slot = int(count_index) - 50
|
|
522
|
+
if tail_slot == 0:
|
|
523
|
+
completed = int(status.mode_play_count("survival"))
|
|
524
|
+
elif tail_slot == 1:
|
|
525
|
+
completed = int(status.mode_play_count("rush"))
|
|
526
|
+
elif tail_slot == 2:
|
|
527
|
+
completed = int(status.mode_play_count("typo"))
|
|
528
|
+
elif tail_slot == 3:
|
|
529
|
+
completed = int(status.mode_play_count("other"))
|
|
530
|
+
elif tail_slot == 4:
|
|
531
|
+
completed = int(status.game_sequence_id)
|
|
532
|
+
elif 5 <= tail_slot <= 8:
|
|
533
|
+
tail = status.unknown_tail()
|
|
534
|
+
off = (tail_slot - 5) * 4
|
|
535
|
+
if len(tail) < off + 4:
|
|
536
|
+
completed = 0
|
|
537
|
+
else:
|
|
538
|
+
completed = int.from_bytes(tail[off : off + 4], "little") & 0xFFFFFFFF
|
|
539
|
+
else:
|
|
540
|
+
completed = 0
|
|
541
|
+
return completed, games
|
|
542
|
+
|
|
543
|
+
def _draw_contents(self) -> None:
|
|
544
|
+
layout = self._layout()
|
|
545
|
+
title_x = layout["title_x"]
|
|
546
|
+
title_y = layout["title_y"]
|
|
547
|
+
icons_x0 = layout["icons_x0"]
|
|
548
|
+
icons_y = layout["icons_y"]
|
|
549
|
+
list_x = layout["list_x"]
|
|
550
|
+
|
|
551
|
+
stage = int(self._stage)
|
|
552
|
+
if stage < 1:
|
|
553
|
+
stage = 1
|
|
554
|
+
if stage > 5:
|
|
555
|
+
stage = 5
|
|
556
|
+
|
|
557
|
+
hovered_stage = self._hovered_stage(layout)
|
|
558
|
+
hovered_row = self._hovered_row(layout)
|
|
559
|
+
show_counts = debug_enabled() and rl.is_key_down(rl.KeyboardKey.KEY_F1)
|
|
560
|
+
|
|
561
|
+
# Title texture is tinted by (0.7, 0.7, 0.7, 0.7).
|
|
562
|
+
title_tex = self._text_quest
|
|
563
|
+
if title_tex is not None:
|
|
564
|
+
rl.draw_texture_pro(
|
|
565
|
+
title_tex,
|
|
566
|
+
rl.Rectangle(0.0, 0.0, float(title_tex.width), float(title_tex.height)),
|
|
567
|
+
rl.Rectangle(title_x, title_y, QUEST_TITLE_W, QUEST_TITLE_H),
|
|
568
|
+
rl.Vector2(0.0, 0.0),
|
|
569
|
+
0.0,
|
|
570
|
+
rl.Color(179, 179, 179, 179),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Stage icons (1..5).
|
|
574
|
+
hover_tint = rl.Color(255, 255, 255, 204) # 0.8 alpha
|
|
575
|
+
base_tint = rl.Color(179, 179, 179, 179) # 0.7 RGBA
|
|
576
|
+
selected_tint = rl.WHITE
|
|
577
|
+
for idx in range(1, 6):
|
|
578
|
+
icon = self._stage_icons.get(idx)
|
|
579
|
+
if icon is None:
|
|
580
|
+
continue
|
|
581
|
+
x = icons_x0 + float(idx - 1) * QUEST_STAGE_ICON_STEP
|
|
582
|
+
local_scale = 1.0 if idx == stage else QUEST_STAGE_ICON_SCALE_UNSELECTED
|
|
583
|
+
size = QUEST_STAGE_ICON_SIZE * local_scale
|
|
584
|
+
tint = base_tint
|
|
585
|
+
if hovered_stage == idx:
|
|
586
|
+
tint = hover_tint
|
|
587
|
+
if idx == stage:
|
|
588
|
+
tint = selected_tint
|
|
589
|
+
rl.draw_texture_pro(
|
|
590
|
+
icon,
|
|
591
|
+
rl.Rectangle(0.0, 0.0, float(icon.width), float(icon.height)),
|
|
592
|
+
rl.Rectangle(x, icons_y, size, size),
|
|
593
|
+
rl.Vector2(0.0, 0.0),
|
|
594
|
+
0.0,
|
|
595
|
+
tint,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
config = self._state.config
|
|
599
|
+
status = self._state.status
|
|
600
|
+
hardcore_flag = bool(int(config.data.get("hardcore_flag", 0) or 0))
|
|
601
|
+
base_color, hover_color = self._quest_row_colors(hardcore=hardcore_flag)
|
|
602
|
+
|
|
603
|
+
font = self._ensure_small_font()
|
|
604
|
+
|
|
605
|
+
y0 = self._rows_y0(layout)
|
|
606
|
+
# Hardcore checkbox (only drawn once tier5 is reachable in normal mode).
|
|
607
|
+
if int(status.quest_unlock_index) >= QUEST_HARDCORE_UNLOCK_INDEX:
|
|
608
|
+
check_on = self._check_on
|
|
609
|
+
check_off = self._check_off
|
|
610
|
+
if check_on is not None and check_off is not None:
|
|
611
|
+
check_tex = check_on if hardcore_flag else check_off
|
|
612
|
+
x = list_x + QUEST_HARDCORE_CHECKBOX_X_OFFSET
|
|
613
|
+
y = layout["list_y0"] + QUEST_HARDCORE_CHECKBOX_Y_OFFSET
|
|
614
|
+
rl.draw_texture_pro(
|
|
615
|
+
check_tex,
|
|
616
|
+
rl.Rectangle(0.0, 0.0, float(check_tex.width), float(check_tex.height)),
|
|
617
|
+
rl.Rectangle(x, y, float(check_tex.width), float(check_tex.height)),
|
|
618
|
+
rl.Vector2(0.0, 0.0),
|
|
619
|
+
0.0,
|
|
620
|
+
rl.WHITE,
|
|
621
|
+
)
|
|
622
|
+
draw_small_text(font, "Hardcore", x + float(check_tex.width) + 6.0, y + 1.0, 1.0, base_color)
|
|
623
|
+
|
|
624
|
+
# Quest list (10 rows).
|
|
625
|
+
for row in range(10):
|
|
626
|
+
y = y0 + float(row) * QUEST_LIST_ROW_STEP
|
|
627
|
+
unlocked = self._quest_unlocked(stage, row)
|
|
628
|
+
color = hover_color if hovered_row == row else base_color
|
|
629
|
+
|
|
630
|
+
draw_small_text(font, f"{stage}.{row + 1}", list_x, y, 1.0, color)
|
|
631
|
+
|
|
632
|
+
if unlocked:
|
|
633
|
+
title = self._quest_title(stage, row)
|
|
634
|
+
else:
|
|
635
|
+
title = "???"
|
|
636
|
+
draw_small_text(font, title, list_x + QUEST_LIST_NAME_X_OFFSET, y, 1.0, color)
|
|
637
|
+
|
|
638
|
+
if show_counts and unlocked:
|
|
639
|
+
counts = self._quest_counts(stage=stage, row=row)
|
|
640
|
+
if counts is not None:
|
|
641
|
+
completed, games = counts
|
|
642
|
+
title_w = measure_small_text_width(font, title, 1.0)
|
|
643
|
+
counts_x = list_x + QUEST_LIST_NAME_X_OFFSET + title_w + 12.0
|
|
644
|
+
draw_small_text(font, f"({completed}/{games})", counts_x, y, 1.0, color)
|
|
645
|
+
|
|
646
|
+
if show_counts:
|
|
647
|
+
# Header is drawn below the list, aligned with the count column.
|
|
648
|
+
header_x = list_x + 96.0
|
|
649
|
+
header_y = y0 + QUEST_LIST_ROW_STEP * 10.0 - 2.0
|
|
650
|
+
draw_small_text(font, "(completed/games)", header_x, header_y, 1.0, base_color)
|
|
651
|
+
|
|
652
|
+
# Back button.
|
|
653
|
+
button = self._button_sm or self._button_md
|
|
654
|
+
if button is not None:
|
|
655
|
+
back_x = list_x + QUEST_BACK_BUTTON_X_OFFSET
|
|
656
|
+
back_y = y0 + QUEST_BACK_BUTTON_Y_OFFSET
|
|
657
|
+
back_w = float(button.width)
|
|
658
|
+
back_h = float(button.height)
|
|
659
|
+
mouse = rl.get_mouse_position()
|
|
660
|
+
hovered = back_x <= mouse.x <= back_x + back_w and back_y <= mouse.y <= back_y + back_h
|
|
661
|
+
rl.draw_texture_pro(
|
|
662
|
+
button,
|
|
663
|
+
rl.Rectangle(0.0, 0.0, float(button.width), float(button.height)),
|
|
664
|
+
rl.Rectangle(back_x, back_y, back_w, back_h),
|
|
665
|
+
rl.Vector2(0.0, 0.0),
|
|
666
|
+
0.0,
|
|
667
|
+
rl.WHITE,
|
|
668
|
+
)
|
|
669
|
+
label = "Back"
|
|
670
|
+
label_w = measure_small_text_width(font, label, 1.0)
|
|
671
|
+
text_x = back_x + (back_w - label_w) * 0.5 + 1.0
|
|
672
|
+
text_y = back_y + 10.0
|
|
673
|
+
text_alpha = 255 if hovered else 179
|
|
674
|
+
draw_small_text(font, label, text_x, text_y, 1.0, rl.Color(255, 255, 255, text_alpha))
|
|
675
|
+
|
|
676
|
+
def _draw_sign(self) -> None:
|
|
677
|
+
assets = self._assets
|
|
678
|
+
if assets is None or assets.sign is None:
|
|
679
|
+
return
|
|
680
|
+
screen_w = float(self._state.config.screen_width)
|
|
681
|
+
scale, shift_x = MenuView._sign_layout_scale(int(screen_w))
|
|
682
|
+
pos_x = screen_w + MENU_SIGN_POS_X_PAD
|
|
683
|
+
pos_y = MENU_SIGN_POS_Y if screen_w > MENU_SCALE_SMALL_THRESHOLD else MENU_SIGN_POS_Y_SMALL
|
|
684
|
+
sign_w = MENU_SIGN_WIDTH * scale
|
|
685
|
+
sign_h = MENU_SIGN_HEIGHT * scale
|
|
686
|
+
offset_x = MENU_SIGN_OFFSET_X * scale + shift_x
|
|
687
|
+
offset_y = MENU_SIGN_OFFSET_Y * scale
|
|
688
|
+
rotation_deg = 0.0
|
|
689
|
+
if not self._state.menu_sign_locked:
|
|
690
|
+
angle_rad, slide_x = MenuView._ui_element_anim(
|
|
691
|
+
self,
|
|
692
|
+
index=0,
|
|
693
|
+
start_ms=300,
|
|
694
|
+
end_ms=0,
|
|
695
|
+
width=sign_w,
|
|
696
|
+
)
|
|
697
|
+
_ = slide_x
|
|
698
|
+
rotation_deg = math.degrees(angle_rad)
|
|
699
|
+
sign = assets.sign
|
|
700
|
+
fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
|
|
701
|
+
if fx_detail:
|
|
702
|
+
MenuView._draw_ui_quad_shadow(
|
|
703
|
+
texture=sign,
|
|
704
|
+
src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
|
|
705
|
+
dst=rl.Rectangle(pos_x + UI_SHADOW_OFFSET, pos_y + UI_SHADOW_OFFSET, sign_w, sign_h),
|
|
706
|
+
origin=rl.Vector2(-offset_x, -offset_y),
|
|
707
|
+
rotation_deg=rotation_deg,
|
|
708
|
+
)
|
|
709
|
+
MenuView._draw_ui_quad(
|
|
710
|
+
texture=sign,
|
|
711
|
+
src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
|
|
712
|
+
dst=rl.Rectangle(pos_x, pos_y, sign_w, sign_h),
|
|
713
|
+
origin=rl.Vector2(-offset_x, -offset_y),
|
|
714
|
+
rotation_deg=rotation_deg,
|
|
715
|
+
tint=rl.WHITE,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
def _draw_panel(self) -> None:
|
|
719
|
+
panel = self._panel_tex
|
|
720
|
+
if panel is None:
|
|
721
|
+
return
|
|
722
|
+
panel_scale = 0.9 if self._menu_screen_width < 641 else 1.0
|
|
723
|
+
dst = rl.Rectangle(
|
|
724
|
+
QUEST_MENU_BASE_X,
|
|
725
|
+
QUEST_MENU_BASE_Y + self._widescreen_y_shift,
|
|
726
|
+
MENU_PANEL_WIDTH * panel_scale,
|
|
727
|
+
MENU_PANEL_HEIGHT * panel_scale,
|
|
728
|
+
)
|
|
729
|
+
origin = rl.Vector2(-(MENU_PANEL_OFFSET_X * panel_scale), -(MENU_PANEL_OFFSET_Y * panel_scale))
|
|
730
|
+
fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
|
|
731
|
+
if fx_detail:
|
|
732
|
+
MenuView._draw_ui_quad_shadow(
|
|
733
|
+
texture=panel,
|
|
734
|
+
src=rl.Rectangle(0.0, 0.0, float(panel.width), float(panel.height)),
|
|
735
|
+
dst=rl.Rectangle(dst.x + UI_SHADOW_OFFSET, dst.y + UI_SHADOW_OFFSET, dst.width, dst.height),
|
|
736
|
+
origin=origin,
|
|
737
|
+
rotation_deg=0.0,
|
|
738
|
+
)
|
|
739
|
+
MenuView._draw_ui_quad(
|
|
740
|
+
texture=panel,
|
|
741
|
+
src=rl.Rectangle(0.0, 0.0, float(panel.width), float(panel.height)),
|
|
742
|
+
dst=dst,
|
|
743
|
+
origin=origin,
|
|
744
|
+
rotation_deg=0.0,
|
|
745
|
+
tint=rl.WHITE,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class QuestStartView(PanelMenuView):
|
|
750
|
+
def __init__(self, state: GameState) -> None:
|
|
751
|
+
super().__init__(
|
|
752
|
+
state,
|
|
753
|
+
title="Quest",
|
|
754
|
+
body="Quest gameplay is not implemented yet.",
|
|
755
|
+
back_action="open_quests",
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
def open(self) -> None:
|
|
759
|
+
level = self._state.pending_quest_level or "unknown"
|
|
760
|
+
self._title = f"Quest {level}"
|
|
761
|
+
self._body_lines = [
|
|
762
|
+
f"Selected quest: {level}",
|
|
763
|
+
"",
|
|
764
|
+
"Quest gameplay is not implemented yet.",
|
|
765
|
+
]
|
|
766
|
+
super().open()
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
class FrontView(Protocol):
|
|
770
|
+
def open(self) -> None: ...
|
|
771
|
+
|
|
772
|
+
def close(self) -> None: ...
|
|
773
|
+
|
|
774
|
+
def update(self, dt: float) -> None: ...
|
|
775
|
+
|
|
776
|
+
def draw(self) -> None: ...
|
|
777
|
+
|
|
778
|
+
def take_action(self) -> str | None: ...
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
class SurvivalGameView:
|
|
782
|
+
"""Gameplay view wrapper that adapts SurvivalMode into `crimson game`."""
|
|
783
|
+
|
|
784
|
+
def __init__(self, state: GameState) -> None:
|
|
785
|
+
from .modes.survival_mode import SurvivalMode
|
|
786
|
+
|
|
787
|
+
self._state = state
|
|
788
|
+
self._mode = SurvivalMode(
|
|
789
|
+
ViewContext(assets_dir=state.assets_dir),
|
|
790
|
+
texture_cache=state.texture_cache,
|
|
791
|
+
config=state.config,
|
|
792
|
+
audio=state.audio,
|
|
793
|
+
audio_rng=state.rng,
|
|
794
|
+
)
|
|
795
|
+
self._action: str | None = None
|
|
796
|
+
|
|
797
|
+
def open(self) -> None:
|
|
798
|
+
self._action = None
|
|
799
|
+
if self._state.screen_fade_ramp:
|
|
800
|
+
self._state.screen_fade_alpha = 1.0
|
|
801
|
+
self._state.screen_fade_ramp = False
|
|
802
|
+
if self._state.audio is not None:
|
|
803
|
+
# Original game: entering gameplay cuts the menu theme; in-game tunes
|
|
804
|
+
# start later on the first creature hit.
|
|
805
|
+
stop_music(self._state.audio)
|
|
806
|
+
self._mode.bind_status(self._state.status)
|
|
807
|
+
self._mode.bind_audio(self._state.audio, self._state.rng)
|
|
808
|
+
self._mode.bind_screen_fade(self._state)
|
|
809
|
+
self._mode.open()
|
|
810
|
+
|
|
811
|
+
def close(self) -> None:
|
|
812
|
+
if self._state.audio is not None:
|
|
813
|
+
stop_music(self._state.audio)
|
|
814
|
+
self._mode.close()
|
|
815
|
+
|
|
816
|
+
def update(self, dt: float) -> None:
|
|
817
|
+
self._mode.update(dt)
|
|
818
|
+
mode_action = self._mode.take_action()
|
|
819
|
+
if mode_action == "open_high_scores":
|
|
820
|
+
self._state.pending_high_scores = HighScoresRequest(game_mode_id=1)
|
|
821
|
+
self._action = "open_high_scores"
|
|
822
|
+
return
|
|
823
|
+
if mode_action == "back_to_menu":
|
|
824
|
+
self._action = "back_to_menu"
|
|
825
|
+
self._mode.close_requested = False
|
|
826
|
+
return
|
|
827
|
+
if getattr(self._mode, "close_requested", False):
|
|
828
|
+
self._action = "back_to_menu"
|
|
829
|
+
self._mode.close_requested = False
|
|
830
|
+
|
|
831
|
+
def draw(self) -> None:
|
|
832
|
+
self._mode.draw()
|
|
833
|
+
|
|
834
|
+
def take_action(self) -> str | None:
|
|
835
|
+
action = self._action
|
|
836
|
+
self._action = None
|
|
837
|
+
return action
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
class RushGameView:
|
|
841
|
+
"""Gameplay view wrapper that adapts RushMode into `crimson game`."""
|
|
842
|
+
|
|
843
|
+
def __init__(self, state: GameState) -> None:
|
|
844
|
+
from .modes.rush_mode import RushMode
|
|
845
|
+
|
|
846
|
+
self._state = state
|
|
847
|
+
self._mode = RushMode(
|
|
848
|
+
ViewContext(assets_dir=state.assets_dir),
|
|
849
|
+
texture_cache=state.texture_cache,
|
|
850
|
+
config=state.config,
|
|
851
|
+
audio=state.audio,
|
|
852
|
+
audio_rng=state.rng,
|
|
853
|
+
)
|
|
854
|
+
self._action: str | None = None
|
|
855
|
+
|
|
856
|
+
def open(self) -> None:
|
|
857
|
+
self._action = None
|
|
858
|
+
if self._state.screen_fade_ramp:
|
|
859
|
+
self._state.screen_fade_alpha = 1.0
|
|
860
|
+
self._state.screen_fade_ramp = False
|
|
861
|
+
if self._state.audio is not None:
|
|
862
|
+
stop_music(self._state.audio)
|
|
863
|
+
self._mode.bind_status(self._state.status)
|
|
864
|
+
self._mode.bind_audio(self._state.audio, self._state.rng)
|
|
865
|
+
self._mode.bind_screen_fade(self._state)
|
|
866
|
+
self._mode.open()
|
|
867
|
+
|
|
868
|
+
def close(self) -> None:
|
|
869
|
+
if self._state.audio is not None:
|
|
870
|
+
stop_music(self._state.audio)
|
|
871
|
+
self._mode.close()
|
|
872
|
+
|
|
873
|
+
def update(self, dt: float) -> None:
|
|
874
|
+
self._mode.update(dt)
|
|
875
|
+
mode_action = self._mode.take_action()
|
|
876
|
+
if mode_action == "open_high_scores":
|
|
877
|
+
self._state.pending_high_scores = HighScoresRequest(game_mode_id=2)
|
|
878
|
+
self._action = "open_high_scores"
|
|
879
|
+
return
|
|
880
|
+
if mode_action == "back_to_menu":
|
|
881
|
+
self._action = "back_to_menu"
|
|
882
|
+
self._mode.close_requested = False
|
|
883
|
+
return
|
|
884
|
+
if getattr(self._mode, "close_requested", False):
|
|
885
|
+
self._action = "back_to_menu"
|
|
886
|
+
self._mode.close_requested = False
|
|
887
|
+
|
|
888
|
+
def draw(self) -> None:
|
|
889
|
+
self._mode.draw()
|
|
890
|
+
|
|
891
|
+
def take_action(self) -> str | None:
|
|
892
|
+
action = self._action
|
|
893
|
+
self._action = None
|
|
894
|
+
return action
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class TypoShooterGameView:
|
|
898
|
+
"""Gameplay view wrapper that adapts TypoShooterMode into `crimson game`."""
|
|
899
|
+
|
|
900
|
+
def __init__(self, state: GameState) -> None:
|
|
901
|
+
from .modes.typo_mode import TypoShooterMode
|
|
902
|
+
|
|
903
|
+
self._state = state
|
|
904
|
+
self._mode = TypoShooterMode(
|
|
905
|
+
ViewContext(assets_dir=state.assets_dir),
|
|
906
|
+
texture_cache=state.texture_cache,
|
|
907
|
+
config=state.config,
|
|
908
|
+
audio=state.audio,
|
|
909
|
+
audio_rng=state.rng,
|
|
910
|
+
)
|
|
911
|
+
self._action: str | None = None
|
|
912
|
+
|
|
913
|
+
def open(self) -> None:
|
|
914
|
+
self._action = None
|
|
915
|
+
if self._state.screen_fade_ramp:
|
|
916
|
+
self._state.screen_fade_alpha = 1.0
|
|
917
|
+
self._state.screen_fade_ramp = False
|
|
918
|
+
if self._state.audio is not None:
|
|
919
|
+
stop_music(self._state.audio)
|
|
920
|
+
self._mode.bind_status(self._state.status)
|
|
921
|
+
self._mode.bind_audio(self._state.audio, self._state.rng)
|
|
922
|
+
self._mode.bind_screen_fade(self._state)
|
|
923
|
+
self._mode.open()
|
|
924
|
+
|
|
925
|
+
def close(self) -> None:
|
|
926
|
+
if self._state.audio is not None:
|
|
927
|
+
stop_music(self._state.audio)
|
|
928
|
+
self._mode.close()
|
|
929
|
+
|
|
930
|
+
def update(self, dt: float) -> None:
|
|
931
|
+
self._mode.update(dt)
|
|
932
|
+
mode_action = self._mode.take_action()
|
|
933
|
+
if mode_action == "open_high_scores":
|
|
934
|
+
self._state.pending_high_scores = HighScoresRequest(game_mode_id=4)
|
|
935
|
+
self._action = "open_high_scores"
|
|
936
|
+
return
|
|
937
|
+
if mode_action == "back_to_menu":
|
|
938
|
+
self._action = "back_to_menu"
|
|
939
|
+
self._mode.close_requested = False
|
|
940
|
+
return
|
|
941
|
+
if getattr(self._mode, "close_requested", False):
|
|
942
|
+
self._action = "back_to_menu"
|
|
943
|
+
self._mode.close_requested = False
|
|
944
|
+
|
|
945
|
+
def draw(self) -> None:
|
|
946
|
+
self._mode.draw()
|
|
947
|
+
|
|
948
|
+
def take_action(self) -> str | None:
|
|
949
|
+
action = self._action
|
|
950
|
+
self._action = None
|
|
951
|
+
return action
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
class TutorialGameView:
|
|
955
|
+
"""Gameplay view wrapper that adapts TutorialMode into `crimson game`."""
|
|
956
|
+
|
|
957
|
+
def __init__(self, state: GameState) -> None:
|
|
958
|
+
from .modes.tutorial_mode import TutorialMode
|
|
959
|
+
|
|
960
|
+
self._state = state
|
|
961
|
+
self._mode = TutorialMode(
|
|
962
|
+
ViewContext(assets_dir=state.assets_dir),
|
|
963
|
+
texture_cache=state.texture_cache,
|
|
964
|
+
config=state.config,
|
|
965
|
+
audio=state.audio,
|
|
966
|
+
audio_rng=state.rng,
|
|
967
|
+
demo_mode_active=state.demo_enabled,
|
|
968
|
+
)
|
|
969
|
+
self._action: str | None = None
|
|
970
|
+
|
|
971
|
+
def open(self) -> None:
|
|
972
|
+
self._action = None
|
|
973
|
+
if self._state.screen_fade_ramp:
|
|
974
|
+
self._state.screen_fade_alpha = 1.0
|
|
975
|
+
self._state.screen_fade_ramp = False
|
|
976
|
+
if self._state.audio is not None:
|
|
977
|
+
stop_music(self._state.audio)
|
|
978
|
+
self._mode.bind_status(self._state.status)
|
|
979
|
+
self._mode.bind_audio(self._state.audio, self._state.rng)
|
|
980
|
+
self._mode.bind_screen_fade(self._state)
|
|
981
|
+
self._mode.open()
|
|
982
|
+
|
|
983
|
+
def close(self) -> None:
|
|
984
|
+
if self._state.audio is not None:
|
|
985
|
+
stop_music(self._state.audio)
|
|
986
|
+
self._mode.close()
|
|
987
|
+
|
|
988
|
+
def update(self, dt: float) -> None:
|
|
989
|
+
self._mode.update(dt)
|
|
990
|
+
if getattr(self._mode, "close_requested", False):
|
|
991
|
+
self._action = "back_to_menu"
|
|
992
|
+
self._mode.close_requested = False
|
|
993
|
+
|
|
994
|
+
def draw(self) -> None:
|
|
995
|
+
self._mode.draw()
|
|
996
|
+
|
|
997
|
+
def take_action(self) -> str | None:
|
|
998
|
+
action = self._action
|
|
999
|
+
self._action = None
|
|
1000
|
+
return action
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
class QuestGameView:
|
|
1004
|
+
"""Gameplay view wrapper that adapts QuestMode into `crimson game`."""
|
|
1005
|
+
|
|
1006
|
+
def __init__(self, state: GameState) -> None:
|
|
1007
|
+
from .modes.quest_mode import QuestMode
|
|
1008
|
+
|
|
1009
|
+
self._state = state
|
|
1010
|
+
self._mode = QuestMode(
|
|
1011
|
+
ViewContext(assets_dir=state.assets_dir),
|
|
1012
|
+
texture_cache=state.texture_cache,
|
|
1013
|
+
config=state.config,
|
|
1014
|
+
audio=state.audio,
|
|
1015
|
+
audio_rng=state.rng,
|
|
1016
|
+
demo_mode_active=state.demo_enabled,
|
|
1017
|
+
)
|
|
1018
|
+
self._action: str | None = None
|
|
1019
|
+
|
|
1020
|
+
def open(self) -> None:
|
|
1021
|
+
self._action = None
|
|
1022
|
+
if self._state.screen_fade_ramp:
|
|
1023
|
+
self._state.screen_fade_alpha = 1.0
|
|
1024
|
+
self._state.screen_fade_ramp = False
|
|
1025
|
+
self._state.quest_outcome = None
|
|
1026
|
+
if self._state.audio is not None:
|
|
1027
|
+
stop_music(self._state.audio)
|
|
1028
|
+
self._mode.bind_status(self._state.status)
|
|
1029
|
+
self._mode.bind_audio(self._state.audio, self._state.rng)
|
|
1030
|
+
self._mode.bind_screen_fade(self._state)
|
|
1031
|
+
self._mode.open()
|
|
1032
|
+
|
|
1033
|
+
level = self._state.pending_quest_level
|
|
1034
|
+
if level is not None:
|
|
1035
|
+
self._mode.prepare_new_run(level, status=self._state.status)
|
|
1036
|
+
|
|
1037
|
+
def close(self) -> None:
|
|
1038
|
+
if self._state.audio is not None:
|
|
1039
|
+
stop_music(self._state.audio)
|
|
1040
|
+
self._mode.close()
|
|
1041
|
+
|
|
1042
|
+
def update(self, dt: float) -> None:
|
|
1043
|
+
self._mode.update(dt)
|
|
1044
|
+
if getattr(self._mode, "close_requested", False):
|
|
1045
|
+
outcome = self._mode.consume_outcome()
|
|
1046
|
+
if outcome is not None:
|
|
1047
|
+
self._state.quest_outcome = outcome
|
|
1048
|
+
if outcome.kind == "completed":
|
|
1049
|
+
self._action = "quest_results"
|
|
1050
|
+
elif outcome.kind == "failed":
|
|
1051
|
+
self._action = "quest_failed"
|
|
1052
|
+
else:
|
|
1053
|
+
self._action = "back_to_menu"
|
|
1054
|
+
else:
|
|
1055
|
+
self._action = "back_to_menu"
|
|
1056
|
+
self._mode.close_requested = False
|
|
1057
|
+
|
|
1058
|
+
def draw(self) -> None:
|
|
1059
|
+
self._mode.draw()
|
|
1060
|
+
|
|
1061
|
+
def take_action(self) -> str | None:
|
|
1062
|
+
action = self._action
|
|
1063
|
+
self._action = None
|
|
1064
|
+
return action
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _player_name_default(config: CrimsonConfig) -> str:
|
|
1068
|
+
raw = config.data.get("player_name")
|
|
1069
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
1070
|
+
return bytes(raw).split(b"\x00", 1)[0].decode("latin-1", errors="ignore")
|
|
1071
|
+
if isinstance(raw, str):
|
|
1072
|
+
return raw
|
|
1073
|
+
return ""
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _next_quest_level(level: str) -> str | None:
|
|
1077
|
+
try:
|
|
1078
|
+
major_text, minor_text = level.split(".", 1)
|
|
1079
|
+
major = int(major_text)
|
|
1080
|
+
minor = int(minor_text)
|
|
1081
|
+
except Exception:
|
|
1082
|
+
return None
|
|
1083
|
+
|
|
1084
|
+
from .quests import quest_by_level
|
|
1085
|
+
|
|
1086
|
+
for _ in range(100):
|
|
1087
|
+
minor += 1
|
|
1088
|
+
if minor > 10:
|
|
1089
|
+
minor = 1
|
|
1090
|
+
major += 1
|
|
1091
|
+
candidate = f"{major}.{minor}"
|
|
1092
|
+
if quest_by_level(candidate) is not None:
|
|
1093
|
+
return candidate
|
|
1094
|
+
return None
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
class QuestResultsView:
|
|
1098
|
+
def __init__(self, state: GameState) -> None:
|
|
1099
|
+
self._state = state
|
|
1100
|
+
self._ground: GroundRenderer | None = None
|
|
1101
|
+
self._outcome: QuestRunOutcome | None = None
|
|
1102
|
+
self._quest_title: str = ""
|
|
1103
|
+
self._quest_stage_major = 0
|
|
1104
|
+
self._quest_stage_minor = 0
|
|
1105
|
+
self._unlock_weapon_name: str = ""
|
|
1106
|
+
self._unlock_perk_name: str = ""
|
|
1107
|
+
self._breakdown = None
|
|
1108
|
+
self._breakdown_anim = None
|
|
1109
|
+
self._record = None
|
|
1110
|
+
self._rank_index: int | None = None
|
|
1111
|
+
self._action: str | None = None
|
|
1112
|
+
self._cursor_pulse_time = 0.0
|
|
1113
|
+
self._small_font: SmallFontData | None = None
|
|
1114
|
+
self._button_tex: rl.Texture2D | None = None
|
|
1115
|
+
|
|
1116
|
+
def open(self) -> None:
|
|
1117
|
+
from .quests.results import QuestResultsBreakdownAnim, compute_quest_final_time
|
|
1118
|
+
from .persistence.highscores import HighScoreRecord, scores_path_for_config, upsert_highscore_record
|
|
1119
|
+
|
|
1120
|
+
self._action = None
|
|
1121
|
+
self._ground = ensure_menu_ground(self._state)
|
|
1122
|
+
self._cursor_pulse_time = 0.0
|
|
1123
|
+
self._outcome = self._state.quest_outcome
|
|
1124
|
+
self._state.quest_outcome = None
|
|
1125
|
+
outcome = self._outcome
|
|
1126
|
+
self._state.quest_fail_retry_count = 0
|
|
1127
|
+
self._quest_title = ""
|
|
1128
|
+
self._quest_stage_major = 0
|
|
1129
|
+
self._quest_stage_minor = 0
|
|
1130
|
+
self._unlock_weapon_name = ""
|
|
1131
|
+
self._unlock_perk_name = ""
|
|
1132
|
+
self._breakdown = None
|
|
1133
|
+
self._breakdown_anim = None
|
|
1134
|
+
self._record = None
|
|
1135
|
+
self._rank_index = None
|
|
1136
|
+
self._button_tex = None
|
|
1137
|
+
self._small_font = None
|
|
1138
|
+
if outcome is None:
|
|
1139
|
+
return
|
|
1140
|
+
|
|
1141
|
+
major, minor = 0, 0
|
|
1142
|
+
try:
|
|
1143
|
+
major_text, minor_text = outcome.level.split(".", 1)
|
|
1144
|
+
major = int(major_text)
|
|
1145
|
+
minor = int(minor_text)
|
|
1146
|
+
except Exception:
|
|
1147
|
+
major = 0
|
|
1148
|
+
minor = 0
|
|
1149
|
+
self._quest_stage_major = int(major)
|
|
1150
|
+
self._quest_stage_minor = int(minor)
|
|
1151
|
+
|
|
1152
|
+
try:
|
|
1153
|
+
from .quests import quest_by_level
|
|
1154
|
+
|
|
1155
|
+
quest = quest_by_level(outcome.level)
|
|
1156
|
+
self._quest_title = quest.title if quest is not None else ""
|
|
1157
|
+
if quest is not None:
|
|
1158
|
+
weapon_id_native = int(quest.unlock_weapon_id or 0)
|
|
1159
|
+
if weapon_id_native > 0:
|
|
1160
|
+
from .weapons import WEAPON_BY_ID
|
|
1161
|
+
|
|
1162
|
+
weapon_entry = WEAPON_BY_ID.get(weapon_id_native)
|
|
1163
|
+
self._unlock_weapon_name = weapon_entry.name if weapon_entry is not None and weapon_entry.name else f"weapon_{weapon_id_native}"
|
|
1164
|
+
|
|
1165
|
+
from .perks import PERK_BY_ID, PerkId, perk_display_name
|
|
1166
|
+
|
|
1167
|
+
perk_id = int(quest.unlock_perk_id or 0)
|
|
1168
|
+
if perk_id != int(PerkId.ANTIPERK):
|
|
1169
|
+
perk_entry = PERK_BY_ID.get(perk_id)
|
|
1170
|
+
if perk_entry is not None and perk_entry.name:
|
|
1171
|
+
fx_toggle = int(self._state.config.data.get("fx_toggle", 0) or 0)
|
|
1172
|
+
self._unlock_perk_name = perk_display_name(perk_id, fx_toggle=fx_toggle)
|
|
1173
|
+
else:
|
|
1174
|
+
self._unlock_perk_name = f"perk_{perk_id}"
|
|
1175
|
+
except Exception:
|
|
1176
|
+
self._quest_title = ""
|
|
1177
|
+
|
|
1178
|
+
record = HighScoreRecord.blank()
|
|
1179
|
+
record.game_mode_id = 3
|
|
1180
|
+
record.quest_stage_major = major
|
|
1181
|
+
record.quest_stage_minor = minor
|
|
1182
|
+
record.score_xp = int(outcome.experience)
|
|
1183
|
+
record.creature_kill_count = int(outcome.kill_count)
|
|
1184
|
+
record.most_used_weapon_id = int(outcome.most_used_weapon_id)
|
|
1185
|
+
fired = max(0, int(outcome.shots_fired))
|
|
1186
|
+
hit = max(0, min(int(outcome.shots_hit), fired))
|
|
1187
|
+
record.shots_fired = fired
|
|
1188
|
+
record.shots_hit = hit
|
|
1189
|
+
|
|
1190
|
+
breakdown = compute_quest_final_time(
|
|
1191
|
+
base_time_ms=int(outcome.base_time_ms),
|
|
1192
|
+
player_health=float(outcome.player_health),
|
|
1193
|
+
player2_health=(float(outcome.player2_health) if outcome.player2_health is not None else None),
|
|
1194
|
+
pending_perk_count=int(outcome.pending_perk_count),
|
|
1195
|
+
)
|
|
1196
|
+
record.survival_elapsed_ms = int(breakdown.final_time_ms)
|
|
1197
|
+
record.set_name(_player_name_default(self._state.config) or "Player")
|
|
1198
|
+
|
|
1199
|
+
global_index = (int(major) - 1) * 10 + (int(minor) - 1)
|
|
1200
|
+
if 0 <= global_index < 40:
|
|
1201
|
+
try:
|
|
1202
|
+
# `sub_447d40` reads completed counts from indices 51..90.
|
|
1203
|
+
self._state.status.increment_quest_play_count(global_index + 51)
|
|
1204
|
+
except Exception:
|
|
1205
|
+
pass
|
|
1206
|
+
|
|
1207
|
+
# Advance quest unlock progression when completing the currently-unlocked quest.
|
|
1208
|
+
if global_index >= 0:
|
|
1209
|
+
next_unlock = int(global_index + 1)
|
|
1210
|
+
hardcore = bool(int(self._state.config.data.get("hardcore_flag", 0) or 0))
|
|
1211
|
+
try:
|
|
1212
|
+
if hardcore:
|
|
1213
|
+
if next_unlock > int(self._state.status.quest_unlock_index_full):
|
|
1214
|
+
self._state.status.quest_unlock_index_full = next_unlock
|
|
1215
|
+
else:
|
|
1216
|
+
if next_unlock > int(self._state.status.quest_unlock_index):
|
|
1217
|
+
self._state.status.quest_unlock_index = next_unlock
|
|
1218
|
+
except Exception:
|
|
1219
|
+
pass
|
|
1220
|
+
|
|
1221
|
+
try:
|
|
1222
|
+
self._state.status.save_if_dirty()
|
|
1223
|
+
except Exception:
|
|
1224
|
+
pass
|
|
1225
|
+
|
|
1226
|
+
path = scores_path_for_config(self._state.base_dir, self._state.config, quest_stage_major=major, quest_stage_minor=minor)
|
|
1227
|
+
try:
|
|
1228
|
+
_table, rank_index = upsert_highscore_record(path, record)
|
|
1229
|
+
self._rank_index = int(rank_index)
|
|
1230
|
+
except Exception:
|
|
1231
|
+
self._rank_index = None
|
|
1232
|
+
|
|
1233
|
+
cache = _ensure_texture_cache(self._state)
|
|
1234
|
+
self._button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
|
|
1235
|
+
self._record = record
|
|
1236
|
+
self._breakdown = breakdown
|
|
1237
|
+
self._breakdown_anim = QuestResultsBreakdownAnim.start()
|
|
1238
|
+
|
|
1239
|
+
def close(self) -> None:
|
|
1240
|
+
self._small_font = None
|
|
1241
|
+
self._button_tex = None
|
|
1242
|
+
self._record = None
|
|
1243
|
+
self._outcome = None
|
|
1244
|
+
self._breakdown = None
|
|
1245
|
+
self._breakdown_anim = None
|
|
1246
|
+
self._rank_index = None
|
|
1247
|
+
self._quest_stage_major = 0
|
|
1248
|
+
self._quest_stage_minor = 0
|
|
1249
|
+
self._unlock_weapon_name = ""
|
|
1250
|
+
self._unlock_perk_name = ""
|
|
1251
|
+
|
|
1252
|
+
def update(self, dt: float) -> None:
|
|
1253
|
+
from .quests.results import tick_quest_results_breakdown_anim
|
|
1254
|
+
|
|
1255
|
+
if self._state.audio is not None:
|
|
1256
|
+
update_audio(self._state.audio, dt)
|
|
1257
|
+
if self._ground is not None:
|
|
1258
|
+
self._ground.process_pending()
|
|
1259
|
+
self._cursor_pulse_time += min(dt, 0.1) * 1.1
|
|
1260
|
+
|
|
1261
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
1262
|
+
self._action = "back_to_menu"
|
|
1263
|
+
return
|
|
1264
|
+
|
|
1265
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_H):
|
|
1266
|
+
self._open_high_scores_list()
|
|
1267
|
+
return
|
|
1268
|
+
|
|
1269
|
+
outcome = self._outcome
|
|
1270
|
+
record = self._record
|
|
1271
|
+
breakdown = self._breakdown
|
|
1272
|
+
if record is None or outcome is None or breakdown is None:
|
|
1273
|
+
return
|
|
1274
|
+
|
|
1275
|
+
anim = self._breakdown_anim
|
|
1276
|
+
if anim is not None and not anim.done:
|
|
1277
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE) or rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
|
|
1278
|
+
anim.set_final(breakdown)
|
|
1279
|
+
return
|
|
1280
|
+
|
|
1281
|
+
clinks = tick_quest_results_breakdown_anim(
|
|
1282
|
+
anim,
|
|
1283
|
+
frame_dt_ms=int(min(dt, 0.1) * 1000.0),
|
|
1284
|
+
target=breakdown,
|
|
1285
|
+
)
|
|
1286
|
+
if clinks > 0 and self._state.audio is not None:
|
|
1287
|
+
play_sfx(self._state.audio, "sfx_ui_clink_01", rng=self._state.rng)
|
|
1288
|
+
if not anim.done:
|
|
1289
|
+
return
|
|
1290
|
+
|
|
1291
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
|
|
1292
|
+
self._state.pending_quest_level = outcome.level
|
|
1293
|
+
self._action = "start_quest"
|
|
1294
|
+
return
|
|
1295
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_N):
|
|
1296
|
+
next_level = _next_quest_level(outcome.level)
|
|
1297
|
+
if next_level is not None:
|
|
1298
|
+
self._state.pending_quest_level = next_level
|
|
1299
|
+
self._action = "start_quest"
|
|
1300
|
+
return
|
|
1301
|
+
|
|
1302
|
+
tex = self._button_tex
|
|
1303
|
+
if tex is None:
|
|
1304
|
+
return
|
|
1305
|
+
scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
|
|
1306
|
+
button_w = float(tex.width) * scale
|
|
1307
|
+
button_h = float(tex.height) * scale
|
|
1308
|
+
gap_x = 18.0 * scale
|
|
1309
|
+
gap_y = 12.0 * scale
|
|
1310
|
+
x0 = 32.0
|
|
1311
|
+
y0 = float(rl.get_screen_height()) - (button_h * 2.0 + gap_y) - 52.0 * scale
|
|
1312
|
+
x1 = x0 + button_w + gap_x
|
|
1313
|
+
y1 = y0 + button_h + gap_y
|
|
1314
|
+
|
|
1315
|
+
buttons = [
|
|
1316
|
+
("Play again", rl.Rectangle(x0, y0, button_w, button_h), "play_again"),
|
|
1317
|
+
("Play next", rl.Rectangle(x1, y0, button_w, button_h), "play_next"),
|
|
1318
|
+
("High scores", rl.Rectangle(x0, y1, button_w, button_h), "high_scores"),
|
|
1319
|
+
("Main menu", rl.Rectangle(x1, y1, button_w, button_h), "main_menu"),
|
|
1320
|
+
]
|
|
1321
|
+
mouse = rl.get_mouse_position()
|
|
1322
|
+
clicked = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
|
|
1323
|
+
for _label, rect, action in buttons:
|
|
1324
|
+
hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
|
|
1325
|
+
if not hovered or not clicked:
|
|
1326
|
+
continue
|
|
1327
|
+
if action == "play_again":
|
|
1328
|
+
self._state.pending_quest_level = outcome.level
|
|
1329
|
+
self._action = "start_quest"
|
|
1330
|
+
return
|
|
1331
|
+
if action == "play_next":
|
|
1332
|
+
next_level = _next_quest_level(outcome.level)
|
|
1333
|
+
if next_level is not None:
|
|
1334
|
+
self._state.pending_quest_level = next_level
|
|
1335
|
+
self._action = "start_quest"
|
|
1336
|
+
return
|
|
1337
|
+
if action == "main_menu":
|
|
1338
|
+
self._action = "back_to_menu"
|
|
1339
|
+
return
|
|
1340
|
+
if action == "high_scores":
|
|
1341
|
+
self._open_high_scores_list()
|
|
1342
|
+
return
|
|
1343
|
+
|
|
1344
|
+
def draw(self) -> None:
|
|
1345
|
+
rl.clear_background(rl.BLACK)
|
|
1346
|
+
if self._ground is not None:
|
|
1347
|
+
self._ground.draw(0.0, 0.0)
|
|
1348
|
+
_draw_screen_fade(self._state)
|
|
1349
|
+
|
|
1350
|
+
record = self._record
|
|
1351
|
+
outcome = self._outcome
|
|
1352
|
+
breakdown = self._breakdown
|
|
1353
|
+
if record is None or outcome is None or breakdown is None:
|
|
1354
|
+
rl.draw_text("Quest results unavailable.", 32, 140, 28, rl.Color(235, 235, 235, 255))
|
|
1355
|
+
rl.draw_text("Press ESC to return to the menu.", 32, 180, 18, rl.Color(190, 190, 200, 255))
|
|
1356
|
+
return
|
|
1357
|
+
|
|
1358
|
+
anim = self._breakdown_anim
|
|
1359
|
+
base_time_ms = int(breakdown.base_time_ms)
|
|
1360
|
+
life_bonus_ms = int(breakdown.life_bonus_ms)
|
|
1361
|
+
perk_bonus_ms = int(breakdown.unpicked_perk_bonus_ms)
|
|
1362
|
+
final_time_ms = int(breakdown.final_time_ms)
|
|
1363
|
+
step = 4
|
|
1364
|
+
highlight_alpha = 1.0
|
|
1365
|
+
if anim is not None and not anim.done:
|
|
1366
|
+
base_time_ms = int(anim.base_time_ms)
|
|
1367
|
+
life_bonus_ms = int(anim.life_bonus_ms)
|
|
1368
|
+
perk_bonus_ms = int(anim.unpicked_perk_bonus_s) * 1000
|
|
1369
|
+
final_time_ms = int(anim.final_time_ms)
|
|
1370
|
+
step = int(anim.step)
|
|
1371
|
+
highlight_alpha = float(anim.highlight_alpha())
|
|
1372
|
+
|
|
1373
|
+
def _fmt_clock(ms: int) -> str:
|
|
1374
|
+
total_seconds = max(0, int(ms) // 1000)
|
|
1375
|
+
minutes = total_seconds // 60
|
|
1376
|
+
seconds = total_seconds % 60
|
|
1377
|
+
return f"{minutes:02d}:{seconds:02d}"
|
|
1378
|
+
|
|
1379
|
+
def _fmt_bonus(ms: int) -> str:
|
|
1380
|
+
return f"-{float(max(0, int(ms))) / 1000.0:.2f}s"
|
|
1381
|
+
|
|
1382
|
+
def _breakdown_color(idx: int, *, final: bool = False) -> rl.Color:
|
|
1383
|
+
if anim is None or anim.done:
|
|
1384
|
+
if final:
|
|
1385
|
+
return rl.Color(255, 255, 255, 255)
|
|
1386
|
+
return rl.Color(255, 255, 255, int(255 * 0.8))
|
|
1387
|
+
|
|
1388
|
+
alpha = 0.2
|
|
1389
|
+
if idx < step:
|
|
1390
|
+
alpha = 0.4
|
|
1391
|
+
elif idx == step:
|
|
1392
|
+
alpha = 1.0
|
|
1393
|
+
if final:
|
|
1394
|
+
alpha *= highlight_alpha
|
|
1395
|
+
rgb = (255, 255, 255)
|
|
1396
|
+
if idx == step:
|
|
1397
|
+
rgb = (25, 200, 25)
|
|
1398
|
+
return rl.Color(rgb[0], rgb[1], rgb[2], int(255 * max(0.0, min(1.0, alpha))))
|
|
1399
|
+
|
|
1400
|
+
title = f"Quest {outcome.level} completed"
|
|
1401
|
+
subtitle = self._quest_title
|
|
1402
|
+
rl.draw_text(title, 32, 120, 28, rl.Color(235, 235, 235, 255))
|
|
1403
|
+
if subtitle:
|
|
1404
|
+
rl.draw_text(subtitle, 32, 154, 18, rl.Color(190, 190, 200, 255))
|
|
1405
|
+
|
|
1406
|
+
font = self._ensure_small_font()
|
|
1407
|
+
text_color = rl.Color(255, 255, 255, int(255 * 0.8))
|
|
1408
|
+
y = 196.0
|
|
1409
|
+
draw_small_text(font, f"Base time: {_fmt_clock(base_time_ms)}", 32.0, y, 1.0, _breakdown_color(0))
|
|
1410
|
+
y += 18.0
|
|
1411
|
+
draw_small_text(font, f"Life bonus: {_fmt_bonus(life_bonus_ms)}", 32.0, y, 1.0, _breakdown_color(1))
|
|
1412
|
+
y += 18.0
|
|
1413
|
+
draw_small_text(font, f"Perk bonus: {_fmt_bonus(perk_bonus_ms)}", 32.0, y, 1.0, _breakdown_color(2))
|
|
1414
|
+
y += 18.0
|
|
1415
|
+
draw_small_text(font, f"Final time: {_fmt_clock(final_time_ms)}", 32.0, y, 1.0, _breakdown_color(3, final=True))
|
|
1416
|
+
y += 26.0
|
|
1417
|
+
draw_small_text(font, f"Kills: {int(record.creature_kill_count)}", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.8)))
|
|
1418
|
+
y += 18.0
|
|
1419
|
+
draw_small_text(font, f"XP: {int(record.score_xp)}", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.8)))
|
|
1420
|
+
if self._rank_index is not None and self._rank_index < 100:
|
|
1421
|
+
y += 18.0
|
|
1422
|
+
draw_small_text(font, f"Rank: {int(self._rank_index) + 1}", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.8)))
|
|
1423
|
+
|
|
1424
|
+
if self._unlock_weapon_name:
|
|
1425
|
+
y += 26.0
|
|
1426
|
+
draw_small_text(font, "Weapon unlocked", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.7)))
|
|
1427
|
+
y += 16.0
|
|
1428
|
+
draw_small_text(font, self._unlock_weapon_name, 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.9)))
|
|
1429
|
+
|
|
1430
|
+
if self._unlock_perk_name:
|
|
1431
|
+
y += 20.0
|
|
1432
|
+
draw_small_text(font, "Perk unlocked", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.7)))
|
|
1433
|
+
y += 16.0
|
|
1434
|
+
draw_small_text(font, self._unlock_perk_name, 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.9)))
|
|
1435
|
+
|
|
1436
|
+
tex = self._button_tex
|
|
1437
|
+
y0 = 0.0
|
|
1438
|
+
if tex is not None:
|
|
1439
|
+
scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
|
|
1440
|
+
button_w = float(tex.width) * scale
|
|
1441
|
+
button_h = float(tex.height) * scale
|
|
1442
|
+
gap_x = 18.0 * scale
|
|
1443
|
+
gap_y = 12.0 * scale
|
|
1444
|
+
x0 = 32.0
|
|
1445
|
+
y0 = float(rl.get_screen_height()) - (button_h * 2.0 + gap_y) - 52.0 * scale
|
|
1446
|
+
x1 = x0 + button_w + gap_x
|
|
1447
|
+
y1 = y0 + button_h + gap_y
|
|
1448
|
+
|
|
1449
|
+
buttons = [
|
|
1450
|
+
("Play again", rl.Rectangle(x0, y0, button_w, button_h)),
|
|
1451
|
+
("Play next", rl.Rectangle(x1, y0, button_w, button_h)),
|
|
1452
|
+
("High scores", rl.Rectangle(x0, y1, button_w, button_h)),
|
|
1453
|
+
("Main menu", rl.Rectangle(x1, y1, button_w, button_h)),
|
|
1454
|
+
]
|
|
1455
|
+
mouse = rl.get_mouse_position()
|
|
1456
|
+
for label, rect in buttons:
|
|
1457
|
+
hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
|
|
1458
|
+
alpha = 255 if hovered else 220
|
|
1459
|
+
rl.draw_texture_pro(
|
|
1460
|
+
tex,
|
|
1461
|
+
rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height)),
|
|
1462
|
+
rect,
|
|
1463
|
+
rl.Vector2(0.0, 0.0),
|
|
1464
|
+
0.0,
|
|
1465
|
+
rl.Color(255, 255, 255, alpha),
|
|
1466
|
+
)
|
|
1467
|
+
label_w = measure_small_text_width(font, label, 1.0 * scale)
|
|
1468
|
+
text_x = rect.x + (rect.width - label_w) * 0.5 + 1.0 * scale
|
|
1469
|
+
text_y = rect.y + 10.0 * scale
|
|
1470
|
+
draw_small_text(font, label, text_x, text_y, 1.0 * scale, rl.Color(20, 20, 20, 255))
|
|
1471
|
+
|
|
1472
|
+
if anim is not None and not anim.done:
|
|
1473
|
+
draw_small_text(
|
|
1474
|
+
font,
|
|
1475
|
+
"SPACE / click: skip breakdown",
|
|
1476
|
+
32.0,
|
|
1477
|
+
float(rl.get_screen_height()) - 46.0,
|
|
1478
|
+
0.9,
|
|
1479
|
+
rl.Color(190, 190, 200, 255),
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
draw_small_text(
|
|
1483
|
+
font,
|
|
1484
|
+
"ENTER: Replay N: Next H: High scores ESC: Menu",
|
|
1485
|
+
32.0,
|
|
1486
|
+
float(rl.get_screen_height()) - 28.0,
|
|
1487
|
+
1.0,
|
|
1488
|
+
rl.Color(190, 190, 200, 255),
|
|
1489
|
+
)
|
|
1490
|
+
_draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
|
|
1491
|
+
|
|
1492
|
+
def take_action(self) -> str | None:
|
|
1493
|
+
action = self._action
|
|
1494
|
+
self._action = None
|
|
1495
|
+
return action
|
|
1496
|
+
|
|
1497
|
+
def _open_high_scores_list(self) -> None:
|
|
1498
|
+
self._state.pending_high_scores = HighScoresRequest(
|
|
1499
|
+
game_mode_id=3,
|
|
1500
|
+
quest_stage_major=int(self._quest_stage_major),
|
|
1501
|
+
quest_stage_minor=int(self._quest_stage_minor),
|
|
1502
|
+
highlight_rank=self._rank_index,
|
|
1503
|
+
)
|
|
1504
|
+
self._action = "open_high_scores"
|
|
1505
|
+
|
|
1506
|
+
def _ensure_small_font(self) -> SmallFontData:
|
|
1507
|
+
if self._small_font is not None:
|
|
1508
|
+
return self._small_font
|
|
1509
|
+
missing_assets: list[str] = []
|
|
1510
|
+
self._small_font = load_small_font(self._state.assets_dir, missing_assets)
|
|
1511
|
+
return self._small_font
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
class QuestFailedView:
|
|
1515
|
+
def __init__(self, state: GameState) -> None:
|
|
1516
|
+
self._state = state
|
|
1517
|
+
self._ground: GroundRenderer | None = None
|
|
1518
|
+
self._outcome: QuestRunOutcome | None = None
|
|
1519
|
+
self._quest_title: str = ""
|
|
1520
|
+
self._action: str | None = None
|
|
1521
|
+
self._cursor_pulse_time = 0.0
|
|
1522
|
+
self._small_font: SmallFontData | None = None
|
|
1523
|
+
self._button_tex: rl.Texture2D | None = None
|
|
1524
|
+
|
|
1525
|
+
def open(self) -> None:
|
|
1526
|
+
self._action = None
|
|
1527
|
+
self._ground = ensure_menu_ground(self._state)
|
|
1528
|
+
self._cursor_pulse_time = 0.0
|
|
1529
|
+
self._outcome = self._state.quest_outcome
|
|
1530
|
+
self._state.quest_outcome = None
|
|
1531
|
+
self._quest_title = ""
|
|
1532
|
+
self._small_font = None
|
|
1533
|
+
self._button_tex = None
|
|
1534
|
+
outcome = self._outcome
|
|
1535
|
+
if outcome is not None:
|
|
1536
|
+
try:
|
|
1537
|
+
from .quests import quest_by_level
|
|
1538
|
+
|
|
1539
|
+
quest = quest_by_level(outcome.level)
|
|
1540
|
+
self._quest_title = quest.title if quest is not None else ""
|
|
1541
|
+
except Exception:
|
|
1542
|
+
self._quest_title = ""
|
|
1543
|
+
|
|
1544
|
+
cache = _ensure_texture_cache(self._state)
|
|
1545
|
+
self._button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
|
|
1546
|
+
|
|
1547
|
+
def close(self) -> None:
|
|
1548
|
+
self._ground = None
|
|
1549
|
+
self._outcome = None
|
|
1550
|
+
self._quest_title = ""
|
|
1551
|
+
self._small_font = None
|
|
1552
|
+
self._button_tex = None
|
|
1553
|
+
|
|
1554
|
+
def update(self, dt: float) -> None:
|
|
1555
|
+
if self._state.audio is not None:
|
|
1556
|
+
update_audio(self._state.audio, dt)
|
|
1557
|
+
if self._ground is not None:
|
|
1558
|
+
self._ground.process_pending()
|
|
1559
|
+
self._cursor_pulse_time += min(dt, 0.1) * 1.1
|
|
1560
|
+
|
|
1561
|
+
outcome = self._outcome
|
|
1562
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
1563
|
+
self._state.quest_fail_retry_count = 0
|
|
1564
|
+
self._action = "back_to_menu"
|
|
1565
|
+
return
|
|
1566
|
+
if outcome is not None and rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
|
|
1567
|
+
self._state.quest_fail_retry_count = int(self._state.quest_fail_retry_count) + 1
|
|
1568
|
+
self._state.pending_quest_level = outcome.level
|
|
1569
|
+
self._action = "start_quest"
|
|
1570
|
+
return
|
|
1571
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_Q):
|
|
1572
|
+
self._state.quest_fail_retry_count = 0
|
|
1573
|
+
self._action = "open_quests"
|
|
1574
|
+
return
|
|
1575
|
+
|
|
1576
|
+
tex = self._button_tex
|
|
1577
|
+
if tex is None or outcome is None:
|
|
1578
|
+
return
|
|
1579
|
+
scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
|
|
1580
|
+
button_w = float(tex.width) * scale
|
|
1581
|
+
button_h = float(tex.height) * scale
|
|
1582
|
+
gap_x = 18.0 * scale
|
|
1583
|
+
x0 = 32.0
|
|
1584
|
+
y0 = float(rl.get_screen_height()) - button_h - 56.0 * scale
|
|
1585
|
+
|
|
1586
|
+
buttons = [
|
|
1587
|
+
("Retry", rl.Rectangle(x0, y0, button_w, button_h), "retry"),
|
|
1588
|
+
("Quest list", rl.Rectangle(x0 + button_w + gap_x, y0, button_w, button_h), "quest_list"),
|
|
1589
|
+
("Main menu", rl.Rectangle(x0 + (button_w + gap_x) * 2.0, y0, button_w, button_h), "main_menu"),
|
|
1590
|
+
]
|
|
1591
|
+
mouse = rl.get_mouse_position()
|
|
1592
|
+
clicked = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
|
|
1593
|
+
for _label, rect, action in buttons:
|
|
1594
|
+
hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
|
|
1595
|
+
if not hovered or not clicked:
|
|
1596
|
+
continue
|
|
1597
|
+
if action == "retry":
|
|
1598
|
+
self._state.quest_fail_retry_count = int(self._state.quest_fail_retry_count) + 1
|
|
1599
|
+
self._state.pending_quest_level = outcome.level
|
|
1600
|
+
self._action = "start_quest"
|
|
1601
|
+
return
|
|
1602
|
+
if action == "quest_list":
|
|
1603
|
+
self._state.quest_fail_retry_count = 0
|
|
1604
|
+
self._action = "open_quests"
|
|
1605
|
+
return
|
|
1606
|
+
if action == "main_menu":
|
|
1607
|
+
self._state.quest_fail_retry_count = 0
|
|
1608
|
+
self._action = "back_to_menu"
|
|
1609
|
+
return
|
|
1610
|
+
|
|
1611
|
+
def draw(self) -> None:
|
|
1612
|
+
rl.clear_background(rl.BLACK)
|
|
1613
|
+
if self._ground is not None:
|
|
1614
|
+
self._ground.draw(0.0, 0.0)
|
|
1615
|
+
_draw_screen_fade(self._state)
|
|
1616
|
+
|
|
1617
|
+
outcome = self._outcome
|
|
1618
|
+
level = outcome.level if outcome is not None else (self._state.pending_quest_level or "unknown")
|
|
1619
|
+
subtitle = self._quest_title
|
|
1620
|
+
rl.draw_text(f"Quest {level} failed", 32, 120, 28, rl.Color(235, 235, 235, 255))
|
|
1621
|
+
if subtitle:
|
|
1622
|
+
rl.draw_text(subtitle, 32, 154, 18, rl.Color(190, 190, 200, 255))
|
|
1623
|
+
|
|
1624
|
+
font = self._ensure_small_font()
|
|
1625
|
+
text_color = rl.Color(255, 255, 255, int(255 * 0.8))
|
|
1626
|
+
retry_count = int(self._state.quest_fail_retry_count)
|
|
1627
|
+
message = "Quest failed, try again."
|
|
1628
|
+
if retry_count == 1:
|
|
1629
|
+
message = "You didn't make it, do try again."
|
|
1630
|
+
elif retry_count == 2:
|
|
1631
|
+
message = "Third time no good."
|
|
1632
|
+
elif retry_count == 3:
|
|
1633
|
+
message = "No luck this time, have another go?"
|
|
1634
|
+
elif retry_count == 4:
|
|
1635
|
+
message = "Persistence will be rewarded."
|
|
1636
|
+
elif retry_count == 5:
|
|
1637
|
+
message = "Try one more time?"
|
|
1638
|
+
|
|
1639
|
+
y = 196.0
|
|
1640
|
+
draw_small_text(font, message, 32.0, y, 1.0, text_color)
|
|
1641
|
+
y += 22.0
|
|
1642
|
+
if outcome is not None:
|
|
1643
|
+
total_seconds = max(0, int(outcome.base_time_ms) // 1000)
|
|
1644
|
+
minutes = total_seconds // 60
|
|
1645
|
+
seconds = total_seconds % 60
|
|
1646
|
+
time_text = f"{minutes:02d}:{seconds:02d}"
|
|
1647
|
+
draw_small_text(font, f"Time: {time_text}", 32.0, y, 1.0, text_color)
|
|
1648
|
+
y += 18.0
|
|
1649
|
+
draw_small_text(font, f"Kills: {int(outcome.kill_count)}", 32.0, y, 1.0, text_color)
|
|
1650
|
+
y += 18.0
|
|
1651
|
+
draw_small_text(font, f"XP: {int(outcome.experience)}", 32.0, y, 1.0, text_color)
|
|
1652
|
+
|
|
1653
|
+
tex = self._button_tex
|
|
1654
|
+
if tex is not None:
|
|
1655
|
+
scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
|
|
1656
|
+
button_w = float(tex.width) * scale
|
|
1657
|
+
button_h = float(tex.height) * scale
|
|
1658
|
+
gap_x = 18.0 * scale
|
|
1659
|
+
x0 = 32.0
|
|
1660
|
+
y0 = float(rl.get_screen_height()) - button_h - 56.0 * scale
|
|
1661
|
+
|
|
1662
|
+
buttons = [
|
|
1663
|
+
("Retry", rl.Rectangle(x0, y0, button_w, button_h)),
|
|
1664
|
+
("Quest list", rl.Rectangle(x0 + button_w + gap_x, y0, button_w, button_h)),
|
|
1665
|
+
("Main menu", rl.Rectangle(x0 + (button_w + gap_x) * 2.0, y0, button_w, button_h)),
|
|
1666
|
+
]
|
|
1667
|
+
mouse = rl.get_mouse_position()
|
|
1668
|
+
for label, rect in buttons:
|
|
1669
|
+
hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
|
|
1670
|
+
alpha = 255 if hovered else 220
|
|
1671
|
+
rl.draw_texture_pro(
|
|
1672
|
+
tex,
|
|
1673
|
+
rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height)),
|
|
1674
|
+
rect,
|
|
1675
|
+
rl.Vector2(0.0, 0.0),
|
|
1676
|
+
0.0,
|
|
1677
|
+
rl.Color(255, 255, 255, alpha),
|
|
1678
|
+
)
|
|
1679
|
+
label_w = measure_small_text_width(font, label, 1.0 * scale)
|
|
1680
|
+
text_x = rect.x + (rect.width - label_w) * 0.5 + 1.0 * scale
|
|
1681
|
+
text_y = rect.y + 10.0 * scale
|
|
1682
|
+
draw_small_text(font, label, text_x, text_y, 1.0 * scale, rl.Color(20, 20, 20, 255))
|
|
1683
|
+
|
|
1684
|
+
draw_small_text(
|
|
1685
|
+
font,
|
|
1686
|
+
"ENTER: Retry Q: Quest list ESC: Menu",
|
|
1687
|
+
32.0,
|
|
1688
|
+
float(rl.get_screen_height()) - 28.0,
|
|
1689
|
+
1.0,
|
|
1690
|
+
rl.Color(190, 190, 200, 255),
|
|
1691
|
+
)
|
|
1692
|
+
_draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
|
|
1693
|
+
|
|
1694
|
+
def take_action(self) -> str | None:
|
|
1695
|
+
action = self._action
|
|
1696
|
+
self._action = None
|
|
1697
|
+
return action
|
|
1698
|
+
|
|
1699
|
+
def _ensure_small_font(self) -> SmallFontData:
|
|
1700
|
+
if self._small_font is not None:
|
|
1701
|
+
return self._small_font
|
|
1702
|
+
missing_assets: list[str] = []
|
|
1703
|
+
self._small_font = load_small_font(self._state.assets_dir, missing_assets)
|
|
1704
|
+
return self._small_font
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
class HighScoresView:
|
|
1708
|
+
def __init__(self, state: GameState) -> None:
|
|
1709
|
+
self._state = state
|
|
1710
|
+
self._ground: GroundRenderer | None = None
|
|
1711
|
+
self._action: str | None = None
|
|
1712
|
+
self._cursor_pulse_time = 0.0
|
|
1713
|
+
self._small_font: SmallFontData | None = None
|
|
1714
|
+
self._button_tex: rl.Texture2D | None = None
|
|
1715
|
+
|
|
1716
|
+
self._request: HighScoresRequest | None = None
|
|
1717
|
+
self._records: list = []
|
|
1718
|
+
self._scroll_index = 0
|
|
1719
|
+
|
|
1720
|
+
def open(self) -> None:
|
|
1721
|
+
from .persistence.highscores import read_highscore_table, scores_path_for_mode
|
|
1722
|
+
|
|
1723
|
+
self._action = None
|
|
1724
|
+
self._ground = ensure_menu_ground(self._state)
|
|
1725
|
+
self._cursor_pulse_time = 0.0
|
|
1726
|
+
self._small_font = None
|
|
1727
|
+
self._scroll_index = 0
|
|
1728
|
+
|
|
1729
|
+
cache = _ensure_texture_cache(self._state)
|
|
1730
|
+
self._button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
|
|
1731
|
+
|
|
1732
|
+
request = self._state.pending_high_scores
|
|
1733
|
+
self._state.pending_high_scores = None
|
|
1734
|
+
if request is None:
|
|
1735
|
+
request = HighScoresRequest(game_mode_id=int(self._state.config.data.get("game_mode", 1) or 1))
|
|
1736
|
+
|
|
1737
|
+
if int(request.game_mode_id) == 3 and (int(request.quest_stage_major) <= 0 or int(request.quest_stage_minor) <= 0):
|
|
1738
|
+
major, minor = self._parse_quest_level(self._state.pending_quest_level)
|
|
1739
|
+
if major <= 0 or minor <= 0:
|
|
1740
|
+
major, minor = self._parse_quest_level(self._state.config.data.get("quest_level"))
|
|
1741
|
+
if major <= 0 or minor <= 0:
|
|
1742
|
+
major = int(self._state.config.data.get("quest_stage_major", 0) or 0)
|
|
1743
|
+
minor = int(self._state.config.data.get("quest_stage_minor", 0) or 0)
|
|
1744
|
+
request.quest_stage_major = int(major)
|
|
1745
|
+
request.quest_stage_minor = int(minor)
|
|
1746
|
+
|
|
1747
|
+
self._request = request
|
|
1748
|
+
path = scores_path_for_mode(
|
|
1749
|
+
self._state.base_dir,
|
|
1750
|
+
int(request.game_mode_id),
|
|
1751
|
+
hardcore=bool(int(self._state.config.data.get("hardcore_flag", 0) or 0)),
|
|
1752
|
+
quest_stage_major=int(request.quest_stage_major),
|
|
1753
|
+
quest_stage_minor=int(request.quest_stage_minor),
|
|
1754
|
+
)
|
|
1755
|
+
try:
|
|
1756
|
+
self._records = read_highscore_table(path, game_mode_id=int(request.game_mode_id))
|
|
1757
|
+
except Exception:
|
|
1758
|
+
self._records = []
|
|
1759
|
+
if self._state.audio is not None:
|
|
1760
|
+
play_sfx(self._state.audio, "sfx_ui_panelclick", rng=self._state.rng)
|
|
1761
|
+
|
|
1762
|
+
def close(self) -> None:
|
|
1763
|
+
if self._small_font is not None:
|
|
1764
|
+
rl.unload_texture(self._small_font.texture)
|
|
1765
|
+
self._small_font = None
|
|
1766
|
+
self._button_tex = None
|
|
1767
|
+
self._request = None
|
|
1768
|
+
self._records = []
|
|
1769
|
+
self._scroll_index = 0
|
|
1770
|
+
|
|
1771
|
+
def update(self, dt: float) -> None:
|
|
1772
|
+
if self._state.audio is not None:
|
|
1773
|
+
update_audio(self._state.audio, dt)
|
|
1774
|
+
if self._ground is not None:
|
|
1775
|
+
self._ground.process_pending()
|
|
1776
|
+
self._cursor_pulse_time += min(dt, 0.1) * 1.1
|
|
1777
|
+
|
|
1778
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
1779
|
+
if self._state.audio is not None:
|
|
1780
|
+
play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
|
|
1781
|
+
self._action = "back_to_previous"
|
|
1782
|
+
return
|
|
1783
|
+
|
|
1784
|
+
mouse = rl.get_mouse_position()
|
|
1785
|
+
clicked = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
|
|
1786
|
+
tex = self._button_tex
|
|
1787
|
+
if tex is not None and clicked:
|
|
1788
|
+
scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
|
|
1789
|
+
button_w = float(tex.width) * scale
|
|
1790
|
+
button_h = float(tex.height) * scale
|
|
1791
|
+
gap_x = 18.0 * scale
|
|
1792
|
+
x0 = 32.0
|
|
1793
|
+
y0 = float(rl.get_screen_height()) - button_h - 52.0 * scale
|
|
1794
|
+
back_rect = rl.Rectangle(x0, y0, button_w, button_h)
|
|
1795
|
+
menu_rect = rl.Rectangle(x0 + button_w + gap_x, y0, button_w, button_h)
|
|
1796
|
+
if back_rect.x <= mouse.x <= back_rect.x + back_rect.width and back_rect.y <= mouse.y <= back_rect.y + back_rect.height:
|
|
1797
|
+
if self._state.audio is not None:
|
|
1798
|
+
play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
|
|
1799
|
+
self._action = "back_to_previous"
|
|
1800
|
+
return
|
|
1801
|
+
if menu_rect.x <= mouse.x <= menu_rect.x + menu_rect.width and menu_rect.y <= mouse.y <= menu_rect.y + menu_rect.height:
|
|
1802
|
+
if self._state.audio is not None:
|
|
1803
|
+
play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
|
|
1804
|
+
self._action = "back_to_menu"
|
|
1805
|
+
return
|
|
1806
|
+
|
|
1807
|
+
font = self._ensure_small_font()
|
|
1808
|
+
rows = self._visible_rows(font)
|
|
1809
|
+
max_scroll = max(0, len(self._records) - rows)
|
|
1810
|
+
|
|
1811
|
+
wheel = int(rl.get_mouse_wheel_move())
|
|
1812
|
+
if wheel:
|
|
1813
|
+
self._scroll_index = max(0, min(max_scroll, int(self._scroll_index) - wheel))
|
|
1814
|
+
|
|
1815
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
|
|
1816
|
+
self._scroll_index = max(0, int(self._scroll_index) - 1)
|
|
1817
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
|
|
1818
|
+
self._scroll_index = min(max_scroll, int(self._scroll_index) + 1)
|
|
1819
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_UP):
|
|
1820
|
+
self._scroll_index = max(0, int(self._scroll_index) - rows)
|
|
1821
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
|
|
1822
|
+
self._scroll_index = min(max_scroll, int(self._scroll_index) + rows)
|
|
1823
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
|
|
1824
|
+
self._scroll_index = 0
|
|
1825
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
|
|
1826
|
+
self._scroll_index = max_scroll
|
|
1827
|
+
|
|
1828
|
+
def draw(self) -> None:
|
|
1829
|
+
rl.clear_background(rl.BLACK)
|
|
1830
|
+
if self._ground is not None:
|
|
1831
|
+
self._ground.draw(0.0, 0.0)
|
|
1832
|
+
_draw_screen_fade(self._state)
|
|
1833
|
+
|
|
1834
|
+
font = self._ensure_small_font()
|
|
1835
|
+
request = self._request
|
|
1836
|
+
mode_id = int(request.game_mode_id) if request is not None else int(self._state.config.data.get("game_mode", 1) or 1)
|
|
1837
|
+
quest_major = int(request.quest_stage_major) if request is not None else 0
|
|
1838
|
+
quest_minor = int(request.quest_stage_minor) if request is not None else 0
|
|
1839
|
+
highlight_rank = request.highlight_rank if request is not None else None
|
|
1840
|
+
|
|
1841
|
+
title = "High scores"
|
|
1842
|
+
subtitle = self._mode_label(mode_id, quest_major, quest_minor)
|
|
1843
|
+
draw_small_text(font, title, 32.0, 120.0, 1.2, rl.Color(235, 235, 235, 255))
|
|
1844
|
+
draw_small_text(font, subtitle, 32.0, 152.0, 1.0, rl.Color(190, 190, 200, 255))
|
|
1845
|
+
|
|
1846
|
+
header_color = rl.Color(255, 255, 255, int(255 * 0.85))
|
|
1847
|
+
row_y0 = 188.0
|
|
1848
|
+
draw_small_text(font, "Rank", 32.0, row_y0, 1.0, header_color)
|
|
1849
|
+
draw_small_text(font, "Name", 96.0, row_y0, 1.0, header_color)
|
|
1850
|
+
score_label = "Score" if mode_id not in (2, 3) else "Time"
|
|
1851
|
+
draw_small_text(font, score_label, 320.0, row_y0, 1.0, header_color)
|
|
1852
|
+
|
|
1853
|
+
row_step = float(font.cell_size)
|
|
1854
|
+
rows = self._visible_rows(font)
|
|
1855
|
+
start = max(0, int(self._scroll_index))
|
|
1856
|
+
end = min(len(self._records), start + rows)
|
|
1857
|
+
y = row_y0 + row_step
|
|
1858
|
+
|
|
1859
|
+
if start >= end:
|
|
1860
|
+
draw_small_text(font, "No scores yet.", 32.0, y + 8.0, 1.0, rl.Color(190, 190, 200, 255))
|
|
1861
|
+
else:
|
|
1862
|
+
for idx in range(start, end):
|
|
1863
|
+
entry = self._records[idx]
|
|
1864
|
+
name = ""
|
|
1865
|
+
try:
|
|
1866
|
+
name = str(entry.name())
|
|
1867
|
+
except Exception:
|
|
1868
|
+
name = ""
|
|
1869
|
+
if not name:
|
|
1870
|
+
name = "???"
|
|
1871
|
+
if len(name) > 16:
|
|
1872
|
+
name = name[:16]
|
|
1873
|
+
|
|
1874
|
+
value = ""
|
|
1875
|
+
if mode_id in (2, 3):
|
|
1876
|
+
seconds = float(int(getattr(entry, "survival_elapsed_ms", 0))) * 0.001
|
|
1877
|
+
value = f"{seconds:7.2f}s"
|
|
1878
|
+
else:
|
|
1879
|
+
value = f"{int(getattr(entry, 'score_xp', 0)):7d}"
|
|
1880
|
+
|
|
1881
|
+
color = rl.Color(255, 255, 255, int(255 * 0.7))
|
|
1882
|
+
if highlight_rank is not None and int(highlight_rank) == idx:
|
|
1883
|
+
color = rl.Color(255, 255, 255, 255)
|
|
1884
|
+
|
|
1885
|
+
draw_small_text(font, f"{idx + 1:>3}", 32.0, y, 1.0, color)
|
|
1886
|
+
draw_small_text(font, name, 96.0, y, 1.0, color)
|
|
1887
|
+
draw_small_text(font, value, 320.0, y, 1.0, color)
|
|
1888
|
+
y += row_step
|
|
1889
|
+
|
|
1890
|
+
tex = self._button_tex
|
|
1891
|
+
if tex is not None:
|
|
1892
|
+
scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
|
|
1893
|
+
button_w = float(tex.width) * scale
|
|
1894
|
+
button_h = float(tex.height) * scale
|
|
1895
|
+
gap_x = 18.0 * scale
|
|
1896
|
+
x0 = 32.0
|
|
1897
|
+
y0 = float(rl.get_screen_height()) - button_h - 52.0 * scale
|
|
1898
|
+
x1 = x0 + button_w + gap_x
|
|
1899
|
+
|
|
1900
|
+
buttons = [
|
|
1901
|
+
("Back", rl.Rectangle(x0, y0, button_w, button_h)),
|
|
1902
|
+
("Main menu", rl.Rectangle(x1, y0, button_w, button_h)),
|
|
1903
|
+
]
|
|
1904
|
+
mouse = rl.get_mouse_position()
|
|
1905
|
+
for label, rect in buttons:
|
|
1906
|
+
hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
|
|
1907
|
+
alpha = 255 if hovered else 220
|
|
1908
|
+
rl.draw_texture_pro(
|
|
1909
|
+
tex,
|
|
1910
|
+
rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height)),
|
|
1911
|
+
rect,
|
|
1912
|
+
rl.Vector2(0.0, 0.0),
|
|
1913
|
+
0.0,
|
|
1914
|
+
rl.Color(255, 255, 255, alpha),
|
|
1915
|
+
)
|
|
1916
|
+
label_w = measure_small_text_width(font, label, 1.0 * scale)
|
|
1917
|
+
text_x = rect.x + (rect.width - label_w) * 0.5 + 1.0 * scale
|
|
1918
|
+
text_y = rect.y + 10.0 * scale
|
|
1919
|
+
draw_small_text(font, label, text_x, text_y, 1.0 * scale, rl.Color(20, 20, 20, 255))
|
|
1920
|
+
|
|
1921
|
+
draw_small_text(
|
|
1922
|
+
font,
|
|
1923
|
+
"UP/DOWN: Scroll PGUP/PGDN: Page ESC: Back",
|
|
1924
|
+
32.0,
|
|
1925
|
+
float(rl.get_screen_height()) - 28.0,
|
|
1926
|
+
1.0,
|
|
1927
|
+
rl.Color(190, 190, 200, 255),
|
|
1928
|
+
)
|
|
1929
|
+
_draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
|
|
1930
|
+
|
|
1931
|
+
def take_action(self) -> str | None:
|
|
1932
|
+
action = self._action
|
|
1933
|
+
self._action = None
|
|
1934
|
+
return action
|
|
1935
|
+
|
|
1936
|
+
def _ensure_small_font(self) -> SmallFontData:
|
|
1937
|
+
if self._small_font is not None:
|
|
1938
|
+
return self._small_font
|
|
1939
|
+
missing_assets: list[str] = []
|
|
1940
|
+
self._small_font = load_small_font(self._state.assets_dir, missing_assets)
|
|
1941
|
+
return self._small_font
|
|
1942
|
+
|
|
1943
|
+
def _visible_rows(self, font: SmallFontData) -> int:
|
|
1944
|
+
row_step = float(font.cell_size)
|
|
1945
|
+
table_top = 188.0 + row_step
|
|
1946
|
+
reserved_bottom = 96.0
|
|
1947
|
+
available = max(0.0, float(rl.get_screen_height()) - table_top - reserved_bottom)
|
|
1948
|
+
return max(1, int(available // row_step))
|
|
1949
|
+
|
|
1950
|
+
@staticmethod
|
|
1951
|
+
def _parse_quest_level(level: str | None) -> tuple[int, int]:
|
|
1952
|
+
if not level:
|
|
1953
|
+
return (0, 0)
|
|
1954
|
+
try:
|
|
1955
|
+
major_text, minor_text = str(level).split(".", 1)
|
|
1956
|
+
return (int(major_text), int(minor_text))
|
|
1957
|
+
except Exception:
|
|
1958
|
+
return (0, 0)
|
|
1959
|
+
|
|
1960
|
+
@staticmethod
|
|
1961
|
+
def _mode_label(mode_id: int, quest_major: int, quest_minor: int) -> str:
|
|
1962
|
+
if int(mode_id) == 1:
|
|
1963
|
+
return "Survival"
|
|
1964
|
+
if int(mode_id) == 2:
|
|
1965
|
+
return "Rush"
|
|
1966
|
+
if int(mode_id) == 4:
|
|
1967
|
+
return "Typ-o Shooter"
|
|
1968
|
+
if int(mode_id) == 3:
|
|
1969
|
+
if int(quest_major) > 0 and int(quest_minor) > 0:
|
|
1970
|
+
return f"Quest {int(quest_major)}.{int(quest_minor)}"
|
|
1971
|
+
return "Quests"
|
|
1972
|
+
return f"Mode {int(mode_id)}"
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
class GameLoopView:
|
|
1976
|
+
def __init__(self, state: GameState) -> None:
|
|
1977
|
+
self._state = state
|
|
1978
|
+
self._boot = BootView(state)
|
|
1979
|
+
self._demo = DemoView(state)
|
|
1980
|
+
self._menu = MenuView(state)
|
|
1981
|
+
self._front_views: dict[str, FrontView] = {
|
|
1982
|
+
"open_play_game": PlayGameMenuView(state),
|
|
1983
|
+
"open_quests": QuestsMenuView(state),
|
|
1984
|
+
"start_quest": QuestGameView(state),
|
|
1985
|
+
"quest_results": QuestResultsView(state),
|
|
1986
|
+
"quest_failed": QuestFailedView(state),
|
|
1987
|
+
"open_high_scores": HighScoresView(state),
|
|
1988
|
+
"start_survival": SurvivalGameView(state),
|
|
1989
|
+
"start_rush": RushGameView(state),
|
|
1990
|
+
"start_typo": TypoShooterGameView(state),
|
|
1991
|
+
"start_tutorial": TutorialGameView(state),
|
|
1992
|
+
"open_options": OptionsMenuView(state),
|
|
1993
|
+
"open_controls": ControlsMenuView(state),
|
|
1994
|
+
"open_statistics": StatisticsMenuView(state),
|
|
1995
|
+
"open_mods": ModsMenuView(state),
|
|
1996
|
+
"open_other_games": PanelMenuView(
|
|
1997
|
+
state,
|
|
1998
|
+
title="Other games",
|
|
1999
|
+
body="This menu is out of scope for the rewrite.",
|
|
2000
|
+
),
|
|
2001
|
+
}
|
|
2002
|
+
self._front_active: FrontView | None = None
|
|
2003
|
+
self._front_stack: list[FrontView] = []
|
|
2004
|
+
self._active: View = self._boot
|
|
2005
|
+
self._demo_trial_overlay = DemoTrialOverlayUi(state.assets_dir)
|
|
2006
|
+
self._demo_trial_info = None
|
|
2007
|
+
self._demo_active = False
|
|
2008
|
+
self._menu_active = False
|
|
2009
|
+
self._quit_after_demo = False
|
|
2010
|
+
self._screenshot_requested = False
|
|
2011
|
+
self._gameplay_views = frozenset(
|
|
2012
|
+
{
|
|
2013
|
+
self._front_views["start_survival"],
|
|
2014
|
+
self._front_views["start_rush"],
|
|
2015
|
+
self._front_views["start_typo"],
|
|
2016
|
+
self._front_views["start_tutorial"],
|
|
2017
|
+
self._front_views["start_quest"],
|
|
2018
|
+
}
|
|
2019
|
+
)
|
|
2020
|
+
|
|
2021
|
+
def open(self) -> None:
|
|
2022
|
+
rl.hide_cursor()
|
|
2023
|
+
self._boot.open()
|
|
2024
|
+
|
|
2025
|
+
def should_close(self) -> bool:
|
|
2026
|
+
return self._state.quit_requested
|
|
2027
|
+
|
|
2028
|
+
def update(self, dt: float) -> None:
|
|
2029
|
+
console = self._state.console
|
|
2030
|
+
console.handle_hotkey()
|
|
2031
|
+
console.update(dt)
|
|
2032
|
+
_update_screen_fade(self._state, dt)
|
|
2033
|
+
if debug_enabled() and (not console.open_flag) and rl.is_key_pressed(rl.KeyboardKey.KEY_P):
|
|
2034
|
+
self._screenshot_requested = True
|
|
2035
|
+
if console.open_flag:
|
|
2036
|
+
if console.quit_requested:
|
|
2037
|
+
self._state.quit_requested = True
|
|
2038
|
+
console.quit_requested = False
|
|
2039
|
+
return
|
|
2040
|
+
|
|
2041
|
+
self._demo_trial_info = None
|
|
2042
|
+
if self._front_active is not None and self._front_active in self._gameplay_views:
|
|
2043
|
+
if self._update_demo_trial_overlay(dt):
|
|
2044
|
+
return
|
|
2045
|
+
|
|
2046
|
+
self._active.update(dt)
|
|
2047
|
+
if self._front_active is not None:
|
|
2048
|
+
action = self._front_active.take_action()
|
|
2049
|
+
if action == "back_to_menu":
|
|
2050
|
+
self._front_active.close()
|
|
2051
|
+
self._front_active = None
|
|
2052
|
+
while self._front_stack:
|
|
2053
|
+
self._front_stack.pop().close()
|
|
2054
|
+
self._menu.open()
|
|
2055
|
+
self._active = self._menu
|
|
2056
|
+
self._menu_active = True
|
|
2057
|
+
return
|
|
2058
|
+
if action == "back_to_previous":
|
|
2059
|
+
if self._front_stack:
|
|
2060
|
+
self._front_active.close()
|
|
2061
|
+
self._front_active = self._front_stack.pop()
|
|
2062
|
+
self._active = self._front_active
|
|
2063
|
+
return
|
|
2064
|
+
self._front_active.close()
|
|
2065
|
+
self._front_active = None
|
|
2066
|
+
self._menu.open()
|
|
2067
|
+
self._active = self._menu
|
|
2068
|
+
self._menu_active = True
|
|
2069
|
+
return
|
|
2070
|
+
if action in {"start_survival", "start_rush", "start_typo"}:
|
|
2071
|
+
# Temporary: bump the counter on mode start so the Play Game overlay (F1)
|
|
2072
|
+
# and Statistics screen reflect activity.
|
|
2073
|
+
mode_name = {
|
|
2074
|
+
"start_survival": "survival",
|
|
2075
|
+
"start_rush": "rush",
|
|
2076
|
+
"start_typo": "typo",
|
|
2077
|
+
}.get(action)
|
|
2078
|
+
if mode_name is not None:
|
|
2079
|
+
self._state.status.increment_mode_play_count(mode_name)
|
|
2080
|
+
if action is not None:
|
|
2081
|
+
view = self._front_views.get(action)
|
|
2082
|
+
if view is not None:
|
|
2083
|
+
if action == "open_high_scores":
|
|
2084
|
+
self._front_stack.append(self._front_active)
|
|
2085
|
+
else:
|
|
2086
|
+
self._front_active.close()
|
|
2087
|
+
view.open()
|
|
2088
|
+
self._front_active = view
|
|
2089
|
+
self._active = view
|
|
2090
|
+
return
|
|
2091
|
+
if self._menu_active:
|
|
2092
|
+
action = self._menu.take_action()
|
|
2093
|
+
if action == "quit_app":
|
|
2094
|
+
self._state.quit_requested = True
|
|
2095
|
+
return
|
|
2096
|
+
if action == "start_demo":
|
|
2097
|
+
self._menu.close()
|
|
2098
|
+
self._menu_active = False
|
|
2099
|
+
self._demo.open()
|
|
2100
|
+
self._active = self._demo
|
|
2101
|
+
self._demo_active = True
|
|
2102
|
+
return
|
|
2103
|
+
if action == "quit_after_demo":
|
|
2104
|
+
self._menu.close()
|
|
2105
|
+
self._menu_active = False
|
|
2106
|
+
self._quit_after_demo = True
|
|
2107
|
+
self._demo.open()
|
|
2108
|
+
self._active = self._demo
|
|
2109
|
+
self._demo_active = True
|
|
2110
|
+
return
|
|
2111
|
+
if action is not None:
|
|
2112
|
+
view = self._front_views.get(action)
|
|
2113
|
+
if view is not None:
|
|
2114
|
+
self._menu.close()
|
|
2115
|
+
self._menu_active = False
|
|
2116
|
+
view.open()
|
|
2117
|
+
self._front_active = view
|
|
2118
|
+
self._active = view
|
|
2119
|
+
return
|
|
2120
|
+
if (
|
|
2121
|
+
(not self._demo_active)
|
|
2122
|
+
and (not self._menu_active)
|
|
2123
|
+
and self._front_active is None
|
|
2124
|
+
and self._state.demo_enabled
|
|
2125
|
+
and self._boot.is_theme_started()
|
|
2126
|
+
):
|
|
2127
|
+
self._demo.open()
|
|
2128
|
+
self._active = self._demo
|
|
2129
|
+
self._demo_active = True
|
|
2130
|
+
return
|
|
2131
|
+
if self._demo_active and not self._menu_active and self._demo.is_finished():
|
|
2132
|
+
self._demo.close()
|
|
2133
|
+
self._demo_active = False
|
|
2134
|
+
if self._quit_after_demo:
|
|
2135
|
+
self._quit_after_demo = False
|
|
2136
|
+
self._state.quit_requested = True
|
|
2137
|
+
return
|
|
2138
|
+
ensure_menu_ground(self._state, regenerate=True)
|
|
2139
|
+
self._menu.open()
|
|
2140
|
+
self._active = self._menu
|
|
2141
|
+
self._menu_active = True
|
|
2142
|
+
return
|
|
2143
|
+
if (not self._demo_active) and (not self._menu_active) and self._front_active is None and self._boot.is_theme_started():
|
|
2144
|
+
self._menu.open()
|
|
2145
|
+
self._active = self._menu
|
|
2146
|
+
self._menu_active = True
|
|
2147
|
+
if console.quit_requested:
|
|
2148
|
+
self._state.quit_requested = True
|
|
2149
|
+
console.quit_requested = False
|
|
2150
|
+
|
|
2151
|
+
def _update_demo_trial_overlay(self, dt: float) -> bool:
|
|
2152
|
+
if not self._state.demo_enabled:
|
|
2153
|
+
return False
|
|
2154
|
+
|
|
2155
|
+
mode_id = int(self._state.config.data.get("game_mode", 0) or 0)
|
|
2156
|
+
quest_major, quest_minor = 0, 0
|
|
2157
|
+
if mode_id == 3:
|
|
2158
|
+
level = self._state.pending_quest_level or ""
|
|
2159
|
+
try:
|
|
2160
|
+
major_text, minor_text = level.split(".", 1)
|
|
2161
|
+
quest_major = int(major_text)
|
|
2162
|
+
quest_minor = int(minor_text)
|
|
2163
|
+
except Exception:
|
|
2164
|
+
quest_major, quest_minor = 0, 0
|
|
2165
|
+
|
|
2166
|
+
current = demo_trial_overlay_info(
|
|
2167
|
+
demo_build=True,
|
|
2168
|
+
game_mode_id=mode_id,
|
|
2169
|
+
global_playtime_ms=int(self._state.status.game_sequence_id),
|
|
2170
|
+
quest_grace_elapsed_ms=int(self._state.demo_trial_elapsed_ms),
|
|
2171
|
+
quest_stage_major=int(quest_major),
|
|
2172
|
+
quest_stage_minor=int(quest_minor),
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
frame_dt = min(float(dt), 0.1)
|
|
2176
|
+
dt_ms = int(frame_dt * 1000.0)
|
|
2177
|
+
used_ms, grace_ms = tick_demo_trial_timers(
|
|
2178
|
+
demo_build=True,
|
|
2179
|
+
game_mode_id=int(mode_id),
|
|
2180
|
+
overlay_visible=bool(current.visible),
|
|
2181
|
+
global_playtime_ms=int(self._state.status.game_sequence_id),
|
|
2182
|
+
quest_grace_elapsed_ms=int(self._state.demo_trial_elapsed_ms),
|
|
2183
|
+
dt_ms=int(dt_ms),
|
|
2184
|
+
)
|
|
2185
|
+
if used_ms != int(self._state.status.game_sequence_id):
|
|
2186
|
+
self._state.status.game_sequence_id = int(used_ms)
|
|
2187
|
+
self._state.demo_trial_elapsed_ms = int(grace_ms)
|
|
2188
|
+
|
|
2189
|
+
info = demo_trial_overlay_info(
|
|
2190
|
+
demo_build=True,
|
|
2191
|
+
game_mode_id=mode_id,
|
|
2192
|
+
global_playtime_ms=int(self._state.status.game_sequence_id),
|
|
2193
|
+
quest_grace_elapsed_ms=int(self._state.demo_trial_elapsed_ms),
|
|
2194
|
+
quest_stage_major=int(quest_major),
|
|
2195
|
+
quest_stage_minor=int(quest_minor),
|
|
2196
|
+
)
|
|
2197
|
+
self._demo_trial_info = info
|
|
2198
|
+
if not info.visible:
|
|
2199
|
+
return False
|
|
2200
|
+
|
|
2201
|
+
self._demo_trial_overlay.bind_cache(self._state.texture_cache)
|
|
2202
|
+
action = self._demo_trial_overlay.update(dt_ms)
|
|
2203
|
+
if action == "purchase":
|
|
2204
|
+
try:
|
|
2205
|
+
webbrowser.open(DEMO_PURCHASE_URL)
|
|
2206
|
+
except Exception:
|
|
2207
|
+
pass
|
|
2208
|
+
return True
|
|
2209
|
+
|
|
2210
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE) or action == "maybe_later":
|
|
2211
|
+
if self._front_active is not None:
|
|
2212
|
+
self._front_active.close()
|
|
2213
|
+
self._front_active = None
|
|
2214
|
+
while self._front_stack:
|
|
2215
|
+
self._front_stack.pop().close()
|
|
2216
|
+
self._menu.open()
|
|
2217
|
+
self._active = self._menu
|
|
2218
|
+
self._menu_active = True
|
|
2219
|
+
return True
|
|
2220
|
+
|
|
2221
|
+
return True
|
|
2222
|
+
|
|
2223
|
+
def consume_screenshot_request(self) -> bool:
|
|
2224
|
+
requested = self._screenshot_requested
|
|
2225
|
+
self._screenshot_requested = False
|
|
2226
|
+
return requested
|
|
2227
|
+
|
|
2228
|
+
def draw(self) -> None:
|
|
2229
|
+
self._active.draw()
|
|
2230
|
+
info = self._demo_trial_info
|
|
2231
|
+
if info is not None and getattr(info, "visible", False):
|
|
2232
|
+
self._demo_trial_overlay.bind_cache(self._state.texture_cache)
|
|
2233
|
+
self._demo_trial_overlay.draw(info)
|
|
2234
|
+
self._state.console.draw()
|
|
2235
|
+
|
|
2236
|
+
def close(self) -> None:
|
|
2237
|
+
if self._menu_active:
|
|
2238
|
+
self._menu.close()
|
|
2239
|
+
if self._front_active is not None:
|
|
2240
|
+
self._front_active.close()
|
|
2241
|
+
while self._front_stack:
|
|
2242
|
+
self._front_stack.pop().close()
|
|
2243
|
+
if self._demo_active:
|
|
2244
|
+
self._demo.close()
|
|
2245
|
+
self._demo_trial_overlay.close()
|
|
2246
|
+
if self._state.menu_ground is not None and self._state.menu_ground.render_target is not None:
|
|
2247
|
+
rl.unload_render_texture(self._state.menu_ground.render_target)
|
|
2248
|
+
self._state.menu_ground.render_target = None
|
|
2249
|
+
self._boot.close()
|
|
2250
|
+
self._state.console.close()
|
|
2251
|
+
rl.show_cursor()
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
def _parse_float_arg(value: str) -> float:
|
|
2255
|
+
try:
|
|
2256
|
+
return float(value)
|
|
2257
|
+
except ValueError:
|
|
2258
|
+
return 0.0
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
def _cvar_float(console: ConsoleState, name: str, default: float = 0.0) -> float:
|
|
2262
|
+
cvar = console.cvars.get(name)
|
|
2263
|
+
if cvar is None:
|
|
2264
|
+
return default
|
|
2265
|
+
return float(cvar.value_f)
|
|
2266
|
+
|
|
2267
|
+
|
|
2268
|
+
def _resolve_resource_paq_path(state: GameState, raw: str) -> Path | None:
|
|
2269
|
+
candidate = Path(raw)
|
|
2270
|
+
if candidate.is_file():
|
|
2271
|
+
return candidate
|
|
2272
|
+
if not candidate.is_absolute():
|
|
2273
|
+
for base in (state.assets_dir, state.base_dir):
|
|
2274
|
+
path = base / candidate
|
|
2275
|
+
if path.is_file():
|
|
2276
|
+
return path
|
|
2277
|
+
return None
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def _boot_command_handlers(state: GameState) -> dict[str, CommandHandler]:
|
|
2281
|
+
console = state.console
|
|
2282
|
+
|
|
2283
|
+
def cmd_set_gamma_ramp(args: list[str]) -> None:
|
|
2284
|
+
if len(args) != 1:
|
|
2285
|
+
console.log.log("setGammaRamp <scalar > 0>")
|
|
2286
|
+
console.log.log(
|
|
2287
|
+
"Command adjusts gamma ramp linearly by multiplying with given scalar"
|
|
2288
|
+
)
|
|
2289
|
+
return
|
|
2290
|
+
value = _parse_float_arg(args[0])
|
|
2291
|
+
state.gamma_ramp = value
|
|
2292
|
+
console.log.log(f"Gamma ramp regenerated and multiplied with {value:.6f}")
|
|
2293
|
+
|
|
2294
|
+
def cmd_snd_add_game_tune(args: list[str]) -> None:
|
|
2295
|
+
if len(args) != 1:
|
|
2296
|
+
console.log.log("snd_addGameTune <tuneName.ogg>")
|
|
2297
|
+
return
|
|
2298
|
+
audio = state.audio
|
|
2299
|
+
if audio is None:
|
|
2300
|
+
return
|
|
2301
|
+
rel_path = f"music/{args[0]}"
|
|
2302
|
+
result = music.load_music_track(audio.music, state.assets_dir, rel_path, console=console)
|
|
2303
|
+
if result is None:
|
|
2304
|
+
return
|
|
2305
|
+
track_key, _track_id = result
|
|
2306
|
+
music.queue_track(audio.music, track_key)
|
|
2307
|
+
|
|
2308
|
+
def cmd_generate_terrain(_args: list[str]) -> None:
|
|
2309
|
+
ensure_menu_ground(state, regenerate=True)
|
|
2310
|
+
|
|
2311
|
+
def cmd_tell_time_survived(_args: list[str]) -> None:
|
|
2312
|
+
seconds = int(max(0.0, time.monotonic() - state.session_start))
|
|
2313
|
+
console.log.log(f"Survived: {seconds} seconds.")
|
|
2314
|
+
|
|
2315
|
+
def cmd_set_resource_paq(args: list[str]) -> None:
|
|
2316
|
+
if len(args) != 1:
|
|
2317
|
+
console.log.log("setresourcepaq <resourcepaq>")
|
|
2318
|
+
return
|
|
2319
|
+
raw = args[0]
|
|
2320
|
+
resolved = _resolve_resource_paq_path(state, raw)
|
|
2321
|
+
if resolved is None:
|
|
2322
|
+
console.log.log(f"File '{raw}' not found.")
|
|
2323
|
+
return
|
|
2324
|
+
entries = load_paq_entries_from_path(resolved)
|
|
2325
|
+
state.resource_paq = resolved
|
|
2326
|
+
if state.texture_cache is None:
|
|
2327
|
+
state.texture_cache = PaqTextureCache(entries=entries, textures={})
|
|
2328
|
+
else:
|
|
2329
|
+
state.texture_cache.entries = entries
|
|
2330
|
+
console.log.log(f"Set resource paq to '{raw}'")
|
|
2331
|
+
|
|
2332
|
+
def cmd_load_texture(args: list[str]) -> None:
|
|
2333
|
+
if len(args) != 1:
|
|
2334
|
+
console.log.log("loadtexture <texturefileid>")
|
|
2335
|
+
return
|
|
2336
|
+
name = args[0]
|
|
2337
|
+
rel_path = name.replace("\\", "/")
|
|
2338
|
+
try:
|
|
2339
|
+
cache = _ensure_texture_cache(state)
|
|
2340
|
+
except FileNotFoundError:
|
|
2341
|
+
console.log.log(f"...loading texture '{name}' failed")
|
|
2342
|
+
return
|
|
2343
|
+
existing = cache.get(name)
|
|
2344
|
+
if existing is not None and existing.texture is not None:
|
|
2345
|
+
return
|
|
2346
|
+
try:
|
|
2347
|
+
asset = cache.get_or_load(name, rel_path)
|
|
2348
|
+
except FileNotFoundError:
|
|
2349
|
+
console.log.log(f"...loading texture '{name}' failed")
|
|
2350
|
+
return
|
|
2351
|
+
if asset.texture is None:
|
|
2352
|
+
console.log.log(f"...loading texture '{name}' failed")
|
|
2353
|
+
return
|
|
2354
|
+
if _cvar_float(console, "cv_silentloads", 0.0) == 0.0:
|
|
2355
|
+
console.log.log(f"...loading texture '{name}' ok")
|
|
2356
|
+
|
|
2357
|
+
def cmd_open_url(args: list[str]) -> None:
|
|
2358
|
+
if len(args) != 1:
|
|
2359
|
+
console.log.log("openurl <url>")
|
|
2360
|
+
return
|
|
2361
|
+
url = args[0]
|
|
2362
|
+
ok = False
|
|
2363
|
+
try:
|
|
2364
|
+
ok = webbrowser.open(url)
|
|
2365
|
+
except Exception:
|
|
2366
|
+
ok = False
|
|
2367
|
+
if ok:
|
|
2368
|
+
console.log.log(f"Launching web browser ({url})..")
|
|
2369
|
+
else:
|
|
2370
|
+
console.log.log("Failed to launch web browser.")
|
|
2371
|
+
|
|
2372
|
+
def cmd_snd_freq_adjustment(_args: list[str]) -> None:
|
|
2373
|
+
state.snd_freq_adjustment_enabled = not state.snd_freq_adjustment_enabled
|
|
2374
|
+
if state.snd_freq_adjustment_enabled:
|
|
2375
|
+
console.log.log("Sound frequency adjustment is now enabled.")
|
|
2376
|
+
else:
|
|
2377
|
+
console.log.log("Sound frequency adjustment is now disabled.")
|
|
2378
|
+
|
|
2379
|
+
def cmd_demo_trial_set_playtime(args: list[str]) -> None:
|
|
2380
|
+
if len(args) != 1:
|
|
2381
|
+
console.log.log("demoTrialSetPlaytime <ms>")
|
|
2382
|
+
return
|
|
2383
|
+
try:
|
|
2384
|
+
value = int(float(args[0]))
|
|
2385
|
+
except ValueError:
|
|
2386
|
+
value = 0
|
|
2387
|
+
state.status.game_sequence_id = max(0, value)
|
|
2388
|
+
state.status.save_if_dirty()
|
|
2389
|
+
console.log.log(f"demo trial: playtime={state.status.game_sequence_id}ms (total {DEMO_TOTAL_PLAY_TIME_MS}ms)")
|
|
2390
|
+
|
|
2391
|
+
def cmd_demo_trial_set_grace(args: list[str]) -> None:
|
|
2392
|
+
if len(args) != 1:
|
|
2393
|
+
console.log.log("demoTrialSetGrace <ms>")
|
|
2394
|
+
return
|
|
2395
|
+
try:
|
|
2396
|
+
value = int(float(args[0]))
|
|
2397
|
+
except ValueError:
|
|
2398
|
+
value = 0
|
|
2399
|
+
state.demo_trial_elapsed_ms = max(0, value)
|
|
2400
|
+
console.log.log(
|
|
2401
|
+
f"demo trial: quest grace={state.demo_trial_elapsed_ms}ms (total {DEMO_QUEST_GRACE_TIME_MS}ms)"
|
|
2402
|
+
)
|
|
2403
|
+
|
|
2404
|
+
def cmd_demo_trial_reset(_args: list[str]) -> None:
|
|
2405
|
+
state.status.game_sequence_id = 0
|
|
2406
|
+
state.status.save_if_dirty()
|
|
2407
|
+
state.demo_trial_elapsed_ms = 0
|
|
2408
|
+
console.log.log("demo trial: timers reset")
|
|
2409
|
+
|
|
2410
|
+
def cmd_demo_trial_info(_args: list[str]) -> None:
|
|
2411
|
+
mode_id = int(state.config.data.get("game_mode", 0) or 0)
|
|
2412
|
+
quest_major = 0
|
|
2413
|
+
quest_minor = 0
|
|
2414
|
+
if mode_id == 3:
|
|
2415
|
+
level = state.pending_quest_level or ""
|
|
2416
|
+
try:
|
|
2417
|
+
major_text, minor_text = level.split(".", 1)
|
|
2418
|
+
quest_major = int(major_text)
|
|
2419
|
+
quest_minor = int(minor_text)
|
|
2420
|
+
except Exception:
|
|
2421
|
+
quest_major, quest_minor = 0, 0
|
|
2422
|
+
info = demo_trial_overlay_info(
|
|
2423
|
+
demo_build=bool(state.demo_enabled),
|
|
2424
|
+
game_mode_id=mode_id,
|
|
2425
|
+
global_playtime_ms=int(state.status.game_sequence_id),
|
|
2426
|
+
quest_grace_elapsed_ms=int(state.demo_trial_elapsed_ms),
|
|
2427
|
+
quest_stage_major=int(quest_major),
|
|
2428
|
+
quest_stage_minor=int(quest_minor),
|
|
2429
|
+
)
|
|
2430
|
+
remaining = format_demo_trial_time(info.remaining_ms)
|
|
2431
|
+
console.log.log(
|
|
2432
|
+
"demo trial: "
|
|
2433
|
+
f"demo={int(state.demo_enabled)} "
|
|
2434
|
+
f"mode={mode_id} "
|
|
2435
|
+
f"quest={quest_major}.{quest_minor} "
|
|
2436
|
+
f"playtime={int(state.status.game_sequence_id)}ms "
|
|
2437
|
+
f"grace={int(state.demo_trial_elapsed_ms)}ms "
|
|
2438
|
+
f"visible={int(info.visible)} "
|
|
2439
|
+
f"kind={info.kind} "
|
|
2440
|
+
f"remaining={remaining}"
|
|
2441
|
+
)
|
|
2442
|
+
|
|
2443
|
+
return {
|
|
2444
|
+
"setGammaRamp": cmd_set_gamma_ramp,
|
|
2445
|
+
"snd_addGameTune": cmd_snd_add_game_tune,
|
|
2446
|
+
"generateterrain": cmd_generate_terrain,
|
|
2447
|
+
"telltimesurvived": cmd_tell_time_survived,
|
|
2448
|
+
"setresourcepaq": cmd_set_resource_paq,
|
|
2449
|
+
"loadtexture": cmd_load_texture,
|
|
2450
|
+
"openurl": cmd_open_url,
|
|
2451
|
+
"sndfreqadjustment": cmd_snd_freq_adjustment,
|
|
2452
|
+
"demoTrialSetPlaytime": cmd_demo_trial_set_playtime,
|
|
2453
|
+
"demoTrialSetGrace": cmd_demo_trial_set_grace,
|
|
2454
|
+
"demoTrialReset": cmd_demo_trial_reset,
|
|
2455
|
+
"demoTrialInfo": cmd_demo_trial_info,
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
def _resolve_assets_dir(config: GameConfig) -> Path:
|
|
2460
|
+
if config.assets_dir is not None:
|
|
2461
|
+
return config.assets_dir
|
|
2462
|
+
return config.base_dir
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
def run_game(config: GameConfig) -> None:
|
|
2466
|
+
base_dir = config.base_dir
|
|
2467
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
2468
|
+
crash_path = base_dir / "crash.log"
|
|
2469
|
+
crash_file = crash_path.open("a", encoding="utf-8", buffering=1)
|
|
2470
|
+
faulthandler.enable(crash_file)
|
|
2471
|
+
crash_file.write(f"\n[{dt.datetime.now().isoformat()}] run_game start\n")
|
|
2472
|
+
cfg = ensure_crimson_cfg(base_dir)
|
|
2473
|
+
width = cfg.screen_width if config.width is None else config.width
|
|
2474
|
+
height = cfg.screen_height if config.height is None else config.height
|
|
2475
|
+
rng = random.Random(config.seed)
|
|
2476
|
+
assets_dir = _resolve_assets_dir(config)
|
|
2477
|
+
console = create_console(base_dir, assets_dir=assets_dir)
|
|
2478
|
+
status = ensure_game_status(base_dir)
|
|
2479
|
+
state: GameState | None = None
|
|
2480
|
+
try:
|
|
2481
|
+
state = GameState(
|
|
2482
|
+
base_dir=base_dir,
|
|
2483
|
+
assets_dir=assets_dir,
|
|
2484
|
+
rng=rng,
|
|
2485
|
+
config=cfg,
|
|
2486
|
+
status=status,
|
|
2487
|
+
console=console,
|
|
2488
|
+
demo_enabled=bool(config.demo_enabled),
|
|
2489
|
+
skip_intro=bool(config.no_intro),
|
|
2490
|
+
logos=None,
|
|
2491
|
+
texture_cache=None,
|
|
2492
|
+
audio=None,
|
|
2493
|
+
resource_paq=assets_dir / CRIMSON_PAQ_NAME,
|
|
2494
|
+
session_start=time.monotonic(),
|
|
2495
|
+
)
|
|
2496
|
+
register_boot_commands(console, _boot_command_handlers(state))
|
|
2497
|
+
register_core_cvars(console, width, height)
|
|
2498
|
+
console.log.log("crimson: boot start")
|
|
2499
|
+
console.log.log(f"config: {cfg.screen_width}x{cfg.screen_height} windowed={cfg.windowed_flag}")
|
|
2500
|
+
console.log.log(f"status: {status.path.name} loaded")
|
|
2501
|
+
console.log.log(f"assets: {assets_dir}")
|
|
2502
|
+
download_missing_paqs(assets_dir, console)
|
|
2503
|
+
if not (assets_dir / CRIMSON_PAQ_NAME).is_file():
|
|
2504
|
+
console.log.log(f"assets: missing {CRIMSON_PAQ_NAME} (textures will not load)")
|
|
2505
|
+
if not (assets_dir / MUSIC_PAQ_NAME).is_file():
|
|
2506
|
+
console.log.log(f"assets: missing {MUSIC_PAQ_NAME}")
|
|
2507
|
+
console.log.log(f"commands: {len(console.commands)} registered")
|
|
2508
|
+
console.log.log(f"cvars: {len(console.cvars)} registered")
|
|
2509
|
+
console.exec_line("exec autoexec.txt")
|
|
2510
|
+
console.log.flush()
|
|
2511
|
+
config_flags = 0
|
|
2512
|
+
if cfg.windowed_flag == 0:
|
|
2513
|
+
config_flags |= rl.ConfigFlags.FLAG_FULLSCREEN_MODE
|
|
2514
|
+
view: View = GameLoopView(state)
|
|
2515
|
+
run_view(
|
|
2516
|
+
view,
|
|
2517
|
+
width=width,
|
|
2518
|
+
height=height,
|
|
2519
|
+
title="Crimsonland",
|
|
2520
|
+
fps=config.fps,
|
|
2521
|
+
config_flags=config_flags,
|
|
2522
|
+
)
|
|
2523
|
+
if state is not None:
|
|
2524
|
+
state.status.save_if_dirty()
|
|
2525
|
+
except Exception:
|
|
2526
|
+
crash_file.write("python exception:\n")
|
|
2527
|
+
crash_file.write(traceback.format_exc())
|
|
2528
|
+
crash_file.write("\n")
|
|
2529
|
+
crash_file.flush()
|
|
2530
|
+
raise
|
|
2531
|
+
finally:
|
|
2532
|
+
faulthandler.disable()
|
|
2533
|
+
crash_file.close()
|