crimsonland 0.1.0.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +155 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +380 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +652 -0
  34. crimson/gameplay.py +2467 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1133 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1941 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +67 -0
  67. crimson/sim/world_state.py +422 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +404 -0
  86. crimson/views/audio_bootstrap.py +47 -0
  87. crimson/views/bonuses.py +201 -0
  88. crimson/views/camera_debug.py +359 -0
  89. crimson/views/camera_shake.py +229 -0
  90. crimson/views/corpse_stamp_debug.py +324 -0
  91. crimson/views/decals_debug.py +739 -0
  92. crimson/views/empty.py +19 -0
  93. crimson/views/fonts.py +114 -0
  94. crimson/views/game_over.py +117 -0
  95. crimson/views/ground.py +259 -0
  96. crimson/views/lighting_debug.py +1166 -0
  97. crimson/views/particles.py +293 -0
  98. crimson/views/perk_menu_debug.py +430 -0
  99. crimson/views/perks.py +398 -0
  100. crimson/views/player.py +434 -0
  101. crimson/views/player_sprite_debug.py +314 -0
  102. crimson/views/projectile_fx.py +609 -0
  103. crimson/views/projectile_render_debug.py +393 -0
  104. crimson/views/projectiles.py +221 -0
  105. crimson/views/quest_title_overlay.py +108 -0
  106. crimson/views/registry.py +34 -0
  107. crimson/views/rush.py +16 -0
  108. crimson/views/small_font_debug.py +204 -0
  109. crimson/views/spawn_plan.py +363 -0
  110. crimson/views/sprites.py +214 -0
  111. crimson/views/survival.py +15 -0
  112. crimson/views/terrain.py +132 -0
  113. crimson/views/ui.py +123 -0
  114. crimson/views/wicons.py +166 -0
  115. crimson/weapon_sfx.py +63 -0
  116. crimson/weapons.py +860 -0
  117. crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
  118. crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
  119. crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
  120. crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
  121. grim/__init__.py +20 -0
  122. grim/app.py +92 -0
  123. grim/assets.py +231 -0
  124. grim/audio.py +106 -0
  125. grim/config.py +294 -0
  126. grim/console.py +737 -0
  127. grim/fonts/__init__.py +7 -0
  128. grim/fonts/grim_mono.py +111 -0
  129. grim/fonts/small.py +120 -0
  130. grim/input.py +44 -0
  131. grim/jaz.py +103 -0
  132. grim/math.py +17 -0
  133. grim/music.py +403 -0
  134. grim/paq.py +76 -0
  135. grim/rand.py +37 -0
  136. grim/sfx.py +276 -0
  137. grim/sfx_map.py +103 -0
  138. grim/terrain_render.py +840 -0
  139. grim/view.py +16 -0
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()