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
@@ -0,0 +1,648 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+ import random
6
+
7
+ import pyray as rl
8
+
9
+ from grim.assets import PaqTextureCache
10
+ from grim.audio import AudioState
11
+ from grim.config import CrimsonConfig
12
+ from grim.view import ViewContext
13
+
14
+ from ..bonuses import BonusId
15
+ from ..creatures.runtime import CreatureFlags
16
+ from ..game_modes import GameMode
17
+ from ..gameplay import PlayerInput, perk_selection_current_choices, perk_selection_pick, survival_check_level_up, weapon_assign_player
18
+ from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
19
+ from ..perks import PerkId, perk_display_description, perk_display_name
20
+ from ..tutorial.timeline import TutorialFrameActions, TutorialState, tick_tutorial_timeline
21
+ from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
22
+ from ..ui.hud import draw_hud_overlay, hud_ui_scale
23
+ from ..ui.perk_menu import (
24
+ PerkMenuAssets,
25
+ PerkMenuLayout,
26
+ UiButtonState,
27
+ button_draw,
28
+ button_update,
29
+ button_width,
30
+ draw_menu_item,
31
+ draw_menu_panel,
32
+ draw_ui_text,
33
+ load_perk_menu_assets,
34
+ menu_item_hit_rect,
35
+ perk_menu_compute_layout,
36
+ ui_origin,
37
+ ui_scale,
38
+ wrap_ui_text,
39
+ )
40
+ from .base_gameplay_mode import BaseGameplayMode
41
+
42
+
43
+ def _clamp(value: float, lo: float, hi: float) -> float:
44
+ if value < lo:
45
+ return lo
46
+ if value > hi:
47
+ return hi
48
+ return value
49
+
50
+
51
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
52
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
53
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
54
+ UI_SPONSOR_COLOR = rl.Color(255, 255, 255, int(255 * 0.5))
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class _TutorialUiLayout:
59
+ panel_y: float = 64.0
60
+ panel_pad_x: float = 20.0
61
+ panel_pad_y: float = 8.0
62
+
63
+
64
+ class TutorialMode(BaseGameplayMode):
65
+ def __init__(
66
+ self,
67
+ ctx: ViewContext,
68
+ *,
69
+ demo_mode_active: bool = False,
70
+ texture_cache: PaqTextureCache | None = None,
71
+ config: CrimsonConfig | None = None,
72
+ audio: AudioState | None = None,
73
+ audio_rng: random.Random | None = None,
74
+ ) -> None:
75
+ super().__init__(
76
+ ctx,
77
+ world_size=1024.0,
78
+ default_game_mode_id=int(GameMode.TUTORIAL),
79
+ demo_mode_active=bool(demo_mode_active),
80
+ difficulty_level=0,
81
+ hardcore=False,
82
+ texture_cache=texture_cache,
83
+ config=config,
84
+ audio=audio,
85
+ audio_rng=audio_rng,
86
+ )
87
+ self._tutorial = TutorialState()
88
+ self._tutorial_actions = TutorialFrameActions()
89
+
90
+ self._ui_assets: PerkMenuAssets | None = None
91
+ self._ui_layout = _TutorialUiLayout()
92
+
93
+ self._perk_ui_layout = PerkMenuLayout()
94
+ self._perk_cancel_button = UiButtonState("Cancel")
95
+ self._perk_menu_open = False
96
+ self._perk_menu_selected = 0
97
+ self._perk_menu_timeline_ms = 0.0
98
+
99
+ self._skip_button = UiButtonState("Skip tutorial", force_wide=True)
100
+ self._play_button = UiButtonState("Play a game", force_wide=True)
101
+ self._repeat_button = UiButtonState("Repeat tutorial", force_wide=True)
102
+
103
+ def open(self) -> None:
104
+ super().open()
105
+ self._ui_assets = load_perk_menu_assets(self._assets_root)
106
+ if self._ui_assets.missing:
107
+ self._missing_assets.extend(self._ui_assets.missing)
108
+
109
+ self._perk_ui_layout = PerkMenuLayout()
110
+ self._perk_cancel_button = UiButtonState("Cancel")
111
+ self._perk_menu_open = False
112
+ self._perk_menu_selected = 0
113
+ self._perk_menu_timeline_ms = 0.0
114
+
115
+ self._skip_button = UiButtonState("Skip tutorial", force_wide=True)
116
+ self._play_button = UiButtonState("Play a game", force_wide=True)
117
+ self._repeat_button = UiButtonState("Repeat tutorial", force_wide=True)
118
+
119
+ self._tutorial = TutorialState()
120
+ self._tutorial_actions = TutorialFrameActions()
121
+
122
+ self._state.perk_selection.pending_count = 0
123
+ self._state.perk_selection.choices.clear()
124
+ self._state.perk_selection.choices_dirty = True
125
+
126
+ self._player.pos_x = float(self._world.world_size) * 0.5
127
+ self._player.pos_y = float(self._world.world_size) * 0.5
128
+ weapon_assign_player(self._player, 1)
129
+
130
+ def close(self) -> None:
131
+ self._ui_assets = None
132
+ super().close()
133
+
134
+ def _handle_input(self) -> None:
135
+ if self._perk_menu_open and rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
136
+ self._perk_menu_open = False
137
+ return
138
+
139
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
140
+ self._paused = not self._paused
141
+
142
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
143
+ self.close_requested = True
144
+
145
+ def _build_input(self) -> PlayerInput:
146
+ keybinds = config_keybinds(self._config)
147
+ if not keybinds:
148
+ keybinds = (0x11, 0x1F, 0x1E, 0x20, 0x100)
149
+ up_key, down_key, left_key, right_key, fire_key = player_move_fire_binds(keybinds, 0)
150
+
151
+ move_x = 0.0
152
+ move_y = 0.0
153
+ if input_code_is_down(left_key):
154
+ move_x -= 1.0
155
+ if input_code_is_down(right_key):
156
+ move_x += 1.0
157
+ if input_code_is_down(up_key):
158
+ move_y -= 1.0
159
+ if input_code_is_down(down_key):
160
+ move_y += 1.0
161
+
162
+ mouse = self._ui_mouse_pos()
163
+ aim_x, aim_y = self._world.screen_to_world(float(mouse.x), float(mouse.y))
164
+
165
+ fire_down = input_code_is_down(fire_key)
166
+ fire_pressed = input_code_is_pressed(fire_key)
167
+ reload_key = 0x102
168
+ if self._config is not None:
169
+ reload_key = int(self._config.data.get("keybind_reload", reload_key) or reload_key)
170
+ reload_pressed = input_code_is_pressed(reload_key)
171
+
172
+ return PlayerInput(
173
+ move_x=move_x,
174
+ move_y=move_y,
175
+ aim_x=float(aim_x),
176
+ aim_y=float(aim_y),
177
+ fire_down=bool(fire_down),
178
+ fire_pressed=bool(fire_pressed),
179
+ reload_pressed=bool(reload_pressed),
180
+ )
181
+
182
+ def _prompt_panel_rect(self, text: str, *, y: float, scale: float) -> tuple[rl.Rectangle, list[str], float]:
183
+ lines = text.splitlines() if text else [""]
184
+ line_h = float(self._ui_line_height(scale))
185
+ max_w = 0.0
186
+ for line in lines:
187
+ max_w = max(max_w, float(self._ui_text_width(line, scale)))
188
+
189
+ pad_x = self._ui_layout.panel_pad_x * scale
190
+ pad_y = self._ui_layout.panel_pad_y * scale
191
+ w = max_w + pad_x * 2.0
192
+ h = float(len(lines)) * line_h + pad_y * 2.0
193
+
194
+ screen_w = float(rl.get_screen_width())
195
+ x = (screen_w - w) * 0.5
196
+ rect = rl.Rectangle(float(x), float(y), float(w), float(h))
197
+ return rect, lines, line_h
198
+
199
+ def _update_prompt_buttons(self, *, dt_ms: float, mouse: rl.Vector2, click: bool) -> None:
200
+ if self._ui_assets is None:
201
+ return
202
+
203
+ stage = int(self._tutorial.stage_index)
204
+ prompt_alpha = float(self._tutorial_actions.prompt_alpha)
205
+ if stage == 8:
206
+ self._play_button.alpha = prompt_alpha
207
+ self._repeat_button.alpha = prompt_alpha
208
+ self._play_button.enabled = prompt_alpha > 1e-3
209
+ self._repeat_button.enabled = prompt_alpha > 1e-3
210
+ else:
211
+ skip_alpha = _clamp(float(self._tutorial.stage_timer_ms - 1000) * 0.001, 0.0, 1.0)
212
+ self._skip_button.alpha = skip_alpha
213
+ self._skip_button.enabled = skip_alpha > 1e-3
214
+
215
+ if stage == 8:
216
+ rect, _lines, _line_h = self._prompt_panel_rect(self._tutorial_actions.prompt_text, y=self._ui_layout.panel_y, scale=1.0)
217
+ gap = 18.0
218
+ button_y = rect.y + rect.height + 10.0
219
+ play_w = button_width(self._small, self._play_button.label, scale=1.0, force_wide=True)
220
+ repeat_w = button_width(self._small, self._repeat_button.label, scale=1.0, force_wide=True)
221
+ play_x = rect.x + 10.0
222
+ repeat_x = play_x + play_w + gap
223
+ if button_update(self._play_button, x=play_x, y=button_y, width=play_w, dt_ms=dt_ms, mouse=mouse, click=click):
224
+ self.close_requested = True
225
+ return
226
+ if button_update(self._repeat_button, x=repeat_x, y=button_y, width=repeat_w, dt_ms=dt_ms, mouse=mouse, click=click):
227
+ self.open()
228
+ return
229
+ return
230
+
231
+ if self._skip_button.enabled:
232
+ y = float(rl.get_screen_height()) - 50.0
233
+ w = button_width(self._small, self._skip_button.label, scale=1.0, force_wide=True)
234
+ if button_update(self._skip_button, x=10.0, y=y, width=w, dt_ms=dt_ms, mouse=mouse, click=click):
235
+ self.close_requested = True
236
+
237
+ def _open_perk_menu(self) -> None:
238
+ if self._ui_assets is None:
239
+ return
240
+ choices = perk_selection_current_choices(
241
+ self._state,
242
+ [self._player],
243
+ self._state.perk_selection,
244
+ game_mode=int(GameMode.TUTORIAL),
245
+ player_count=1,
246
+ )
247
+ if not choices:
248
+ self._perk_menu_open = False
249
+ return
250
+ self._perk_menu_open = True
251
+ self._perk_menu_selected = 0
252
+
253
+ def _perk_menu_handle_input(self, dt_frame: float, dt_ms: float) -> None:
254
+ if self._ui_assets is None:
255
+ self._perk_menu_open = False
256
+ return
257
+
258
+ perk_state = self._state.perk_selection
259
+ choices = perk_selection_current_choices(
260
+ self._state,
261
+ [self._player],
262
+ perk_state,
263
+ game_mode=int(GameMode.TUTORIAL),
264
+ player_count=1,
265
+ )
266
+ if not choices:
267
+ self._perk_menu_open = False
268
+ return
269
+ if self._perk_menu_selected >= len(choices):
270
+ self._perk_menu_selected = 0
271
+
272
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
273
+ self._perk_menu_selected = (self._perk_menu_selected + 1) % len(choices)
274
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
275
+ self._perk_menu_selected = (self._perk_menu_selected - 1) % len(choices)
276
+
277
+ screen_w = float(rl.get_screen_width())
278
+ screen_h = float(rl.get_screen_height())
279
+ scale = ui_scale(screen_w, screen_h)
280
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
281
+
282
+ mouse = self._ui_mouse_pos()
283
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
284
+
285
+ master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
286
+ expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
287
+ computed = perk_menu_compute_layout(
288
+ self._perk_ui_layout,
289
+ screen_w=screen_w,
290
+ origin_x=origin_x,
291
+ origin_y=origin_y,
292
+ scale=scale,
293
+ choice_count=len(choices),
294
+ expert_owned=expert_owned,
295
+ master_owned=master_owned,
296
+ )
297
+
298
+ fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
299
+ for idx, perk_id in enumerate(choices):
300
+ label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
301
+ item_x = computed.list_x
302
+ item_y = computed.list_y + float(idx) * computed.list_step_y
303
+ rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
304
+ if rl.check_collision_point_rec(mouse, rect):
305
+ self._perk_menu_selected = idx
306
+ if click:
307
+ perk_selection_pick(
308
+ self._state,
309
+ [self._player],
310
+ perk_state,
311
+ idx,
312
+ game_mode=int(GameMode.TUTORIAL),
313
+ player_count=1,
314
+ dt=dt_frame,
315
+ creatures=self._creatures.entries,
316
+ )
317
+ self._perk_menu_open = False
318
+ return
319
+ break
320
+
321
+ cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
322
+ cancel_x = computed.cancel_x
323
+ cancel_y = computed.cancel_y
324
+ if button_update(self._perk_cancel_button, x=cancel_x, y=cancel_y, width=cancel_w, dt_ms=dt_ms, mouse=mouse, click=click):
325
+ self._perk_menu_open = False
326
+ return
327
+
328
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) or rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
329
+ perk_selection_pick(
330
+ self._state,
331
+ [self._player],
332
+ perk_state,
333
+ self._perk_menu_selected,
334
+ game_mode=int(GameMode.TUTORIAL),
335
+ player_count=1,
336
+ dt=dt_frame,
337
+ creatures=self._creatures.entries,
338
+ )
339
+ self._perk_menu_open = False
340
+
341
+ def update(self, dt: float) -> None:
342
+ self._update_audio(dt)
343
+ dt_frame, dt_ui_ms = self._tick_frame(dt, clamp_cursor_pulse=True)
344
+
345
+ self._handle_input()
346
+ if self.close_requested:
347
+ return
348
+
349
+ perk_pending = int(self._state.perk_selection.pending_count) > 0 and self._player.health > 0.0
350
+ if int(self._tutorial.stage_index) == 6 and perk_pending and not self._perk_menu_open:
351
+ self._open_perk_menu()
352
+
353
+ perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
354
+ if self._perk_menu_open:
355
+ self._perk_menu_handle_input(dt_frame, dt_ui_ms)
356
+
357
+ if self._perk_menu_open:
358
+ self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms + dt_ui_ms, 0.0, 200.0)
359
+ else:
360
+ self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms - dt_ui_ms, 0.0, 200.0)
361
+
362
+ dt_world = 0.0 if self._paused or perk_menu_active else dt_frame
363
+
364
+ input_state = self._build_input()
365
+ any_move_active = bool(input_state.move_x or input_state.move_y)
366
+ any_fire_active = bool(input_state.fire_pressed or input_state.fire_down)
367
+
368
+ hint_alive_before = False
369
+ hint_ref = self._tutorial.hint_bonus_creature_ref
370
+ if hint_ref is not None and 0 <= int(hint_ref) < len(self._creatures.entries):
371
+ entry = self._creatures.entries[int(hint_ref)]
372
+ hint_alive_before = bool(entry.active and entry.hp > 0.0)
373
+
374
+ if dt_world > 0.0:
375
+ self._world.update(
376
+ dt_world,
377
+ inputs=[input_state],
378
+ auto_pick_perks=False,
379
+ game_mode=int(GameMode.TUTORIAL),
380
+ perk_progression_enabled=True,
381
+ )
382
+
383
+ hint_alive_after = hint_alive_before
384
+ if hint_ref is not None and 0 <= int(hint_ref) < len(self._creatures.entries):
385
+ entry = self._creatures.entries[int(hint_ref)]
386
+ hint_alive_after = bool(entry.active and entry.hp > 0.0)
387
+ hint_bonus_died = hint_alive_before and (not hint_alive_after)
388
+
389
+ creatures_none_active = not bool(self._creatures.iter_active())
390
+ bonus_pool_empty = not bool(self._state.bonus_pool.iter_active())
391
+ perk_pending_count = int(self._state.perk_selection.pending_count)
392
+
393
+ self._tutorial, actions = tick_tutorial_timeline(
394
+ self._tutorial,
395
+ frame_dt_ms=dt_world * 1000.0,
396
+ any_move_active=any_move_active,
397
+ any_fire_active=any_fire_active,
398
+ creatures_none_active=creatures_none_active,
399
+ bonus_pool_empty=bonus_pool_empty,
400
+ perk_pending_count=perk_pending_count,
401
+ hint_bonus_died=hint_bonus_died,
402
+ )
403
+ self._tutorial_actions = actions
404
+
405
+ self._player.health = float(actions.force_player_health)
406
+ if actions.force_player_experience is not None:
407
+ self._player.experience = int(actions.force_player_experience)
408
+ survival_check_level_up(self._player, self._state.perk_selection)
409
+
410
+ for call in actions.spawn_bonuses:
411
+ spawned = self._state.bonus_pool.spawn_at(
412
+ float(call.pos[0]),
413
+ float(call.pos[1]),
414
+ int(call.bonus_id),
415
+ int(call.amount),
416
+ world_width=float(self._world.world_size),
417
+ world_height=float(self._world.world_size),
418
+ )
419
+ if spawned is not None:
420
+ self._state.effects.spawn_burst(
421
+ pos_x=float(spawned.pos_x),
422
+ pos_y=float(spawned.pos_y),
423
+ count=12,
424
+ rand=self._state.rng.rand,
425
+ detail_preset=5,
426
+ )
427
+
428
+ for call in actions.spawn_templates:
429
+ mapping, primary = self._creatures.spawn_template(
430
+ int(call.template_id),
431
+ call.pos,
432
+ float(call.heading),
433
+ self._state.rng,
434
+ rand=self._state.rng.rand,
435
+ )
436
+ if int(call.template_id) == 0x27 and primary is not None and actions.stage5_bonus_carrier_drop is not None:
437
+ drop_id, drop_amount = actions.stage5_bonus_carrier_drop
438
+ self._tutorial.hint_bonus_creature_ref = int(primary)
439
+ if 0 <= int(primary) < len(self._creatures.entries):
440
+ creature = self._creatures.entries[int(primary)]
441
+ creature.flags |= CreatureFlags.BONUS_ON_DEATH
442
+ creature.bonus_id = int(drop_id)
443
+ creature.bonus_duration_override = int(drop_amount)
444
+
445
+ mouse = self._ui_mouse_pos()
446
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
447
+ self._update_prompt_buttons(dt_ms=dt_ui_ms, mouse=mouse, click=click)
448
+
449
+ def draw(self) -> None:
450
+ perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
451
+ self._world.draw(draw_aim_indicators=not perk_menu_active)
452
+ self._draw_screen_fade()
453
+
454
+ hud_bottom = 0.0
455
+ if (not perk_menu_active) and self._hud_assets is not None:
456
+ hud_bottom = draw_hud_overlay(
457
+ self._hud_assets,
458
+ player=self._player,
459
+ players=self._world.players,
460
+ bonus_hud=self._state.bonus_hud,
461
+ elapsed_ms=float(self._tutorial.stage_timer_ms),
462
+ score=int(self._player.experience),
463
+ font=self._small,
464
+ alpha=1.0,
465
+ frame_dt_ms=self._last_dt_ms,
466
+ )
467
+
468
+ self._draw_tutorial_prompts(hud_bottom=hud_bottom)
469
+
470
+ warn_y = float(rl.get_screen_height()) - 28.0
471
+ if self._world.missing_assets:
472
+ warn = "Missing world assets: " + ", ".join(self._world.missing_assets)
473
+ self._draw_ui_text(warn, 24.0, warn_y, UI_ERROR_COLOR, scale=0.8)
474
+ warn_y -= float(self._ui_line_height(scale=0.8)) + 2.0
475
+ if self._hud_missing:
476
+ warn = "Missing HUD assets: " + ", ".join(self._hud_missing)
477
+ self._draw_ui_text(warn, 24.0, warn_y, UI_ERROR_COLOR, scale=0.8)
478
+
479
+ if perk_menu_active:
480
+ self._draw_perk_menu()
481
+ self._draw_menu_cursor()
482
+ else:
483
+ self._draw_aim_cursor()
484
+
485
+ def _draw_tutorial_prompts(self, *, hud_bottom: float) -> None:
486
+ actions = self._tutorial_actions
487
+ if actions.prompt_text and actions.prompt_alpha > 1e-3:
488
+ self._draw_prompt_panel(actions.prompt_text, alpha=float(actions.prompt_alpha), y=self._ui_layout.panel_y)
489
+ if actions.hint_text and actions.hint_alpha > 1e-3:
490
+ y = self._ui_layout.panel_y + 84.0
491
+ self._draw_prompt_panel(actions.hint_text, alpha=float(actions.hint_alpha), y=y)
492
+
493
+ if self._ui_assets is None:
494
+ return
495
+
496
+ stage = int(self._tutorial.stage_index)
497
+ mouse = self._ui_mouse_pos()
498
+ scale = hud_ui_scale(float(rl.get_screen_width()), float(rl.get_screen_height()))
499
+ if stage == 8:
500
+ rect, _lines, _line_h = self._prompt_panel_rect(actions.prompt_text, y=self._ui_layout.panel_y, scale=1.0)
501
+ gap = 18.0
502
+ button_y = rect.y + rect.height + 10.0
503
+ play_w = button_width(self._small, self._play_button.label, scale=1.0, force_wide=True)
504
+ repeat_w = button_width(self._small, self._repeat_button.label, scale=1.0, force_wide=True)
505
+ play_x = rect.x + 10.0
506
+ repeat_x = play_x + play_w + gap
507
+ button_draw(self._ui_assets, self._small, self._play_button, x=play_x, y=button_y, width=play_w, scale=1.0)
508
+ button_draw(self._ui_assets, self._small, self._repeat_button, x=repeat_x, y=button_y, width=repeat_w, scale=1.0)
509
+ return
510
+
511
+ if self._skip_button.alpha > 1e-3:
512
+ y = float(rl.get_screen_height()) - 50.0
513
+ w = button_width(self._small, self._skip_button.label, scale=1.0, force_wide=True)
514
+ button_draw(self._ui_assets, self._small, self._skip_button, x=10.0, y=y, width=w, scale=1.0)
515
+
516
+ if self._paused:
517
+ x = 18.0
518
+ y = max(18.0, hud_bottom + 10.0)
519
+ self._draw_ui_text("paused (TAB)", x, y, UI_HINT_COLOR)
520
+
521
+ def _draw_prompt_panel(self, text: str, *, alpha: float, y: float) -> None:
522
+ alpha = _clamp(float(alpha), 0.0, 1.0)
523
+ rect, lines, line_h = self._prompt_panel_rect(text, y=float(y), scale=1.0)
524
+ fill = rl.Color(0, 0, 0, int(255 * alpha * 0.8))
525
+ border = rl.Color(255, 255, 255, int(255 * alpha))
526
+ rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), fill)
527
+ rl.draw_rectangle_lines(int(rect.x), int(rect.y), int(rect.width), int(rect.height), border)
528
+
529
+ text_alpha = int(255 * _clamp(alpha * 0.9, 0.0, 1.0))
530
+ color = rl.Color(255, 255, 255, text_alpha)
531
+ x = float(rect.x + self._ui_layout.panel_pad_x)
532
+ line_y = float(rect.y + self._ui_layout.panel_pad_y)
533
+ for line in lines:
534
+ self._draw_ui_text(line, x, line_y, color, scale=1.0)
535
+ line_y += line_h
536
+
537
+ def _draw_menu_cursor(self) -> None:
538
+ assets = self._ui_assets
539
+ if assets is None:
540
+ return
541
+ cursor_tex = assets.cursor
542
+ draw_menu_cursor(
543
+ self._world.particles_texture,
544
+ cursor_tex,
545
+ x=float(self._ui_mouse_x),
546
+ y=float(self._ui_mouse_y),
547
+ pulse_time=float(self._cursor_pulse_time),
548
+ )
549
+
550
+ def _draw_aim_cursor(self) -> None:
551
+ assets = self._ui_assets
552
+ if assets is None:
553
+ return
554
+ aim_tex = assets.aim
555
+ draw_aim_cursor(
556
+ self._world.particles_texture,
557
+ aim_tex,
558
+ x=float(self._ui_mouse_x),
559
+ y=float(self._ui_mouse_y),
560
+ )
561
+
562
+ def _draw_perk_menu(self) -> None:
563
+ assets = self._ui_assets
564
+ if assets is None:
565
+ return
566
+ perk_state = self._state.perk_selection
567
+ choices = perk_selection_current_choices(
568
+ self._state,
569
+ [self._player],
570
+ perk_state,
571
+ game_mode=int(GameMode.TUTORIAL),
572
+ player_count=1,
573
+ )
574
+ if not choices:
575
+ return
576
+
577
+ screen_w = float(rl.get_screen_width())
578
+ screen_h = float(rl.get_screen_height())
579
+ scale = ui_scale(screen_w, screen_h)
580
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
581
+
582
+ master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
583
+ expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
584
+ computed = perk_menu_compute_layout(
585
+ self._perk_ui_layout,
586
+ screen_w=screen_w,
587
+ origin_x=origin_x,
588
+ origin_y=origin_y,
589
+ scale=scale,
590
+ choice_count=len(choices),
591
+ expert_owned=expert_owned,
592
+ master_owned=master_owned,
593
+ )
594
+
595
+ panel_tex = assets.menu_panel
596
+ if panel_tex is not None:
597
+ draw_menu_panel(panel_tex, dst=computed.panel)
598
+
599
+ title_tex = assets.title_pick_perk
600
+ if title_tex is not None:
601
+ src = rl.Rectangle(0.0, 0.0, float(title_tex.width), float(title_tex.height))
602
+ rl.draw_texture_pro(title_tex, src, computed.title, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
603
+
604
+ sponsor = None
605
+ if master_owned:
606
+ sponsor = "extra perks sponsored by the Perk Master"
607
+ elif expert_owned:
608
+ sponsor = "extra perk sponsored by the Perk Expert"
609
+ if sponsor:
610
+ draw_ui_text(
611
+ self._small,
612
+ sponsor,
613
+ computed.sponsor_x,
614
+ computed.sponsor_y,
615
+ scale=scale,
616
+ color=UI_SPONSOR_COLOR,
617
+ )
618
+
619
+ mouse = self._ui_mouse_pos()
620
+ fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
621
+ for idx, perk_id in enumerate(choices):
622
+ label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
623
+ item_x = computed.list_x
624
+ item_y = computed.list_y + float(idx) * computed.list_step_y
625
+ rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
626
+ hovered = rl.check_collision_point_rec(mouse, rect) or (idx == self._perk_menu_selected)
627
+ draw_menu_item(self._small, label, x=item_x, y=item_y, scale=scale, hovered=hovered)
628
+
629
+ selected = choices[self._perk_menu_selected]
630
+ desc = perk_display_description(int(selected), fx_toggle=fx_toggle)
631
+ desc_x = float(computed.desc.x)
632
+ desc_y = float(computed.desc.y)
633
+ desc_w = float(computed.desc.width)
634
+ desc_h = float(computed.desc.height)
635
+ desc_scale = scale * 0.85
636
+ desc_lines = wrap_ui_text(self._small, desc, max_width=desc_w, scale=desc_scale)
637
+ line_h = float(self._small.cell_size * desc_scale) if self._small is not None else float(20 * desc_scale)
638
+ y = desc_y
639
+ for line in desc_lines:
640
+ if y + line_h > desc_y + desc_h:
641
+ break
642
+ draw_ui_text(self._small, line, desc_x, y, scale=desc_scale, color=UI_TEXT_COLOR)
643
+ y += line_h
644
+
645
+ cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
646
+ cancel_x = computed.cancel_x
647
+ cancel_y = computed.cancel_y
648
+ button_draw(assets, self._small, self._perk_cancel_button, x=cancel_x, y=cancel_y, width=cancel_w, scale=scale)