crimsonland 0.1.0.dev7__py3-none-any.whl → 0.1.0.dev9__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.
@@ -7,24 +7,42 @@ import pyray as rl
7
7
 
8
8
  from grim.assets import PaqTextureCache
9
9
  from grim.audio import AudioState
10
+ from grim.console import ConsoleState
10
11
  from grim.config import CrimsonConfig
11
12
  from grim.fonts.grim_mono import GrimMonoFont, load_grim_mono_font
12
13
  from grim.view import ViewContext
13
14
 
14
15
  from ..game_modes import GameMode
15
- from ..gameplay import most_used_weapon_id_for_player, weapon_assign_player
16
+ from ..gameplay import most_used_weapon_id_for_player, perk_selection_current_choices, perk_selection_pick, weapon_assign_player
16
17
  from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
17
18
  from ..persistence.save_status import GameStatus
19
+ from ..perks import PerkId, perk_display_description, perk_display_name
18
20
  from ..quests import quest_by_level
19
21
  from ..quests.runtime import build_quest_spawn_table, tick_quest_completion_transition
20
22
  from ..quests.timeline import quest_spawn_table_empty, tick_quest_mode_spawns
21
23
  from ..quests.types import QuestContext, QuestDefinition, SpawnEntry
22
24
  from ..terrain_assets import terrain_texture_by_id
23
25
  from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
24
- from ..ui.hud import draw_hud_overlay, hud_ui_scale
25
- from ..ui.perk_menu import PerkMenuAssets, load_perk_menu_assets
26
+ from ..ui.hud import draw_hud_overlay, hud_flags_for_game_mode
27
+ from ..ui.perk_menu import (
28
+ PerkMenuAssets,
29
+ PerkMenuLayout,
30
+ UiButtonState,
31
+ button_draw,
32
+ button_update,
33
+ button_width,
34
+ draw_menu_item,
35
+ draw_menu_panel,
36
+ draw_ui_text,
37
+ load_perk_menu_assets,
38
+ menu_item_hit_rect,
39
+ perk_menu_compute_layout,
40
+ ui_origin,
41
+ ui_scale,
42
+ wrap_ui_text,
43
+ )
26
44
  from ..views.quest_title_overlay import draw_quest_title_overlay
27
- from .base_gameplay_mode import BaseGameplayMode
45
+ from .base_gameplay_mode import BaseGameplayMode, _clamp
28
46
 
29
47
  WORLD_SIZE = 1024.0
30
48
  QUEST_TITLE_FADE_IN_MS = 500.0
@@ -32,6 +50,36 @@ QUEST_TITLE_HOLD_MS = 1000.0
32
50
  QUEST_TITLE_FADE_OUT_MS = 500.0
33
51
  QUEST_TITLE_TOTAL_MS = QUEST_TITLE_FADE_IN_MS + QUEST_TITLE_HOLD_MS + QUEST_TITLE_FADE_OUT_MS
34
52
 
53
+ UI_TEXT_SCALE = 1.0
54
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
55
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
56
+ UI_SPONSOR_COLOR = rl.Color(255, 255, 255, int(255 * 0.5))
57
+
58
+ PERK_PROMPT_MAX_TIMER_MS = 200.0
59
+ PERK_PROMPT_OUTSET_X = 50.0
60
+ # Perk prompt bar geometry comes from `ui_menu_assets_init` + `ui_menu_layout_init`:
61
+ # - `ui_menu_item_element` is set_rect(512x64, offset -72,-60)
62
+ # - the perk prompt mutates quad coords: x = (x - 300) * 0.75, y = y * 0.75
63
+ PERK_PROMPT_BAR_SCALE = 0.75
64
+ PERK_PROMPT_BAR_BASE_OFFSET_X = -72.0
65
+ PERK_PROMPT_BAR_BASE_OFFSET_Y = -60.0
66
+ PERK_PROMPT_BAR_SHIFT_X = -300.0
67
+
68
+ # `ui_textLevelUp` is set_rect(75x25, offset -230,-27), then its quad coords are:
69
+ # x = x * 0.85 - 46, y = y * 0.85 - 4
70
+ PERK_PROMPT_LEVEL_UP_SCALE = 0.85
71
+ PERK_PROMPT_LEVEL_UP_BASE_OFFSET_X = -230.0
72
+ PERK_PROMPT_LEVEL_UP_BASE_OFFSET_Y = -27.0
73
+ PERK_PROMPT_LEVEL_UP_BASE_W = 75.0
74
+ PERK_PROMPT_LEVEL_UP_BASE_H = 25.0
75
+ PERK_PROMPT_LEVEL_UP_SHIFT_X = -46.0
76
+ PERK_PROMPT_LEVEL_UP_SHIFT_Y = -4.0
77
+
78
+ PERK_PROMPT_TEXT_MARGIN_X = 16.0
79
+ PERK_PROMPT_TEXT_OFFSET_Y = 8.0
80
+
81
+ PERK_MENU_TRANSITION_MS = 500.0
82
+
35
83
 
36
84
  @dataclass(slots=True)
37
85
  class _QuestRunState:
@@ -99,6 +147,7 @@ class QuestMode(BaseGameplayMode):
99
147
  demo_mode_active: bool = False,
100
148
  texture_cache: PaqTextureCache | None = None,
101
149
  config: CrimsonConfig | None = None,
150
+ console: ConsoleState | None = None,
102
151
  audio: AudioState | None = None,
103
152
  audio_rng: random.Random | None = None,
104
153
  ) -> None:
@@ -111,32 +160,51 @@ class QuestMode(BaseGameplayMode):
111
160
  hardcore=False,
112
161
  texture_cache=texture_cache,
113
162
  config=config,
163
+ console=console,
114
164
  audio=audio,
115
165
  audio_rng=audio_rng,
116
166
  )
117
167
  self._quest = _QuestRunState()
118
168
  self._selected_level: str | None = None
119
169
  self._outcome: QuestRunOutcome | None = None
120
- self._ui_assets: PerkMenuAssets | None = None
170
+ self._perk_menu_assets: PerkMenuAssets | None = None
121
171
  self._grim_mono: GrimMonoFont | None = None
122
172
 
173
+ self._perk_prompt_timer_ms = 0.0
174
+ self._perk_prompt_hover = False
175
+ self._perk_prompt_pulse = 0.0
176
+ self._perk_menu_open = False
177
+ self._perk_menu_selected = 0
178
+ self._perk_menu_timeline_ms = 0.0
179
+ self._perk_ui_layout = PerkMenuLayout()
180
+ self._perk_cancel_button = UiButtonState("Cancel")
181
+
123
182
  def open(self) -> None:
124
183
  super().open()
125
184
  self._quest = _QuestRunState()
126
185
  self._outcome = None
127
- self._ui_assets = load_perk_menu_assets(self._assets_root)
128
- if self._ui_assets.missing:
129
- self._missing_assets.extend(self._ui_assets.missing)
186
+ self._perk_menu_assets = load_perk_menu_assets(self._assets_root)
187
+ if self._perk_menu_assets.missing:
188
+ self._missing_assets.extend(self._perk_menu_assets.missing)
130
189
  try:
131
190
  self._grim_mono = load_grim_mono_font(self._assets_root, self._missing_assets)
132
191
  except Exception:
133
192
  self._grim_mono = None
134
193
 
194
+ self._perk_prompt_timer_ms = 0.0
195
+ self._perk_prompt_hover = False
196
+ self._perk_prompt_pulse = 0.0
197
+ self._perk_menu_open = False
198
+ self._perk_menu_selected = 0
199
+ self._perk_menu_timeline_ms = 0.0
200
+ self._perk_ui_layout = PerkMenuLayout()
201
+ self._perk_cancel_button = UiButtonState("Cancel")
202
+
135
203
  def close(self) -> None:
136
204
  if self._grim_mono is not None:
137
205
  rl.unload_texture(self._grim_mono.texture)
138
206
  self._grim_mono = None
139
- self._ui_assets = None
207
+ self._perk_menu_assets = None
140
208
  super().close()
141
209
 
142
210
  def select_level(self, level: str | None) -> None:
@@ -229,6 +297,11 @@ class QuestMode(BaseGameplayMode):
229
297
  status.increment_quest_play_count(idx)
230
298
 
231
299
  def _handle_input(self) -> None:
300
+ if self._perk_menu_open and rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
301
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
302
+ self._close_perk_menu()
303
+ return
304
+
232
305
  if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
233
306
  self._paused = not self._paused
234
307
 
@@ -274,22 +347,381 @@ class QuestMode(BaseGameplayMode):
274
347
  reload_pressed=bool(reload_pressed),
275
348
  )
276
349
 
350
+ def _perk_prompt_label(self) -> str:
351
+ if self._config is not None and not bool(int(self._config.data.get("ui_info_texts", 1) or 0)):
352
+ return ""
353
+ pending = int(self._state.perk_selection.pending_count)
354
+ if pending <= 0:
355
+ return ""
356
+ suffix = f" ({pending})" if pending > 1 else ""
357
+ return f"Press Mouse2 to pick a perk{suffix}"
358
+
359
+ def _perk_prompt_hinge(self) -> tuple[float, float]:
360
+ screen_w = float(rl.get_screen_width())
361
+ hinge_x = screen_w + PERK_PROMPT_OUTSET_X
362
+ hinge_y = 80.0 if int(screen_w) == 640 else 40.0
363
+ return hinge_x, hinge_y
364
+
365
+ def _perk_prompt_rect(self, label: str, *, scale: float = UI_TEXT_SCALE) -> rl.Rectangle:
366
+ hinge_x, hinge_y = self._perk_prompt_hinge()
367
+ if self._perk_menu_assets is not None and self._perk_menu_assets.menu_item is not None:
368
+ tex = self._perk_menu_assets.menu_item
369
+ bar_w = float(tex.width) * PERK_PROMPT_BAR_SCALE
370
+ bar_h = float(tex.height) * PERK_PROMPT_BAR_SCALE
371
+ local_x = (PERK_PROMPT_BAR_BASE_OFFSET_X + PERK_PROMPT_BAR_SHIFT_X) * PERK_PROMPT_BAR_SCALE
372
+ local_y = PERK_PROMPT_BAR_BASE_OFFSET_Y * PERK_PROMPT_BAR_SCALE
373
+ return rl.Rectangle(
374
+ float(hinge_x + local_x),
375
+ float(hinge_y + local_y),
376
+ float(bar_w),
377
+ float(bar_h),
378
+ )
379
+
380
+ margin = 16.0 * scale
381
+ text_w = float(self._ui_text_width(label, scale))
382
+ text_h = float(self._ui_line_height(scale))
383
+ x = float(rl.get_screen_width()) - margin - text_w
384
+ y = margin
385
+ return rl.Rectangle(x, y, text_w, text_h)
386
+
387
+ def _open_perk_menu(self) -> None:
388
+ if self._perk_menu_open:
389
+ return
390
+ players = self._world.players
391
+ choices = perk_selection_current_choices(
392
+ self._state,
393
+ players,
394
+ self._state.perk_selection,
395
+ game_mode=int(GameMode.QUESTS),
396
+ player_count=len(players),
397
+ )
398
+ if not choices:
399
+ self._perk_menu_open = False
400
+ return
401
+ self._world.audio_router.play_sfx("sfx_ui_panelclick")
402
+ self._perk_menu_open = True
403
+ self._perk_menu_selected = 0
404
+
405
+ def _close_perk_menu(self) -> None:
406
+ self._perk_menu_open = False
407
+ if int(self._state.perk_selection.pending_count) > 0:
408
+ # Reset the prompt swing so each pending perk replays the intro.
409
+ self._perk_prompt_timer_ms = 0.0
410
+ self._perk_prompt_hover = False
411
+ self._perk_prompt_pulse = 0.0
412
+
413
+ def _perk_menu_handle_input(self, dt_frame: float, dt_ms: float) -> None:
414
+ if self._perk_menu_assets is None:
415
+ self._close_perk_menu()
416
+ return
417
+
418
+ perk_state = self._state.perk_selection
419
+ players = self._world.players
420
+ choices = perk_selection_current_choices(
421
+ self._state,
422
+ players,
423
+ perk_state,
424
+ game_mode=int(GameMode.QUESTS),
425
+ player_count=len(players),
426
+ )
427
+ if not choices:
428
+ self._close_perk_menu()
429
+ return
430
+ if self._perk_menu_selected >= len(choices):
431
+ self._perk_menu_selected = 0
432
+
433
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
434
+ self._perk_menu_selected = (self._perk_menu_selected + 1) % len(choices)
435
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
436
+ self._perk_menu_selected = (self._perk_menu_selected - 1) % len(choices)
437
+
438
+ screen_w = float(rl.get_screen_width())
439
+ screen_h = float(rl.get_screen_height())
440
+ scale = ui_scale(screen_w, screen_h)
441
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
442
+ menu_t = _clamp(self._perk_menu_timeline_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
443
+ slide_x = (menu_t - 1.0) * (self._perk_ui_layout.panel_w * scale)
444
+
445
+ mouse = self._ui_mouse_pos()
446
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
447
+
448
+ master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
449
+ expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
450
+ computed = perk_menu_compute_layout(
451
+ self._perk_ui_layout,
452
+ screen_w=screen_w,
453
+ origin_x=origin_x + slide_x,
454
+ origin_y=origin_y,
455
+ scale=scale,
456
+ choice_count=len(choices),
457
+ expert_owned=expert_owned,
458
+ master_owned=master_owned,
459
+ )
460
+
461
+ fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
462
+ for idx, perk_id in enumerate(choices):
463
+ label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
464
+ item_x = computed.list_x
465
+ item_y = computed.list_y + float(idx) * computed.list_step_y
466
+ rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
467
+ if rl.check_collision_point_rec(mouse, rect):
468
+ self._perk_menu_selected = idx
469
+ if click:
470
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
471
+ picked = perk_selection_pick(
472
+ self._state,
473
+ players,
474
+ perk_state,
475
+ idx,
476
+ game_mode=int(GameMode.QUESTS),
477
+ player_count=len(players),
478
+ dt=dt_frame,
479
+ creatures=self._creatures.entries,
480
+ )
481
+ if picked is not None:
482
+ self._world.audio_router.play_sfx("sfx_ui_bonus")
483
+ self._close_perk_menu()
484
+ return
485
+ break
486
+
487
+ cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
488
+ cancel_x = computed.cancel_x
489
+ button_y = computed.cancel_y
490
+
491
+ if button_update(
492
+ self._perk_cancel_button,
493
+ x=cancel_x,
494
+ y=button_y,
495
+ width=cancel_w,
496
+ dt_ms=dt_ms,
497
+ mouse=mouse,
498
+ click=click,
499
+ ):
500
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
501
+ self._close_perk_menu()
502
+ return
503
+
504
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) or rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
505
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
506
+ picked = perk_selection_pick(
507
+ self._state,
508
+ players,
509
+ perk_state,
510
+ self._perk_menu_selected,
511
+ game_mode=int(GameMode.QUESTS),
512
+ player_count=len(players),
513
+ dt=dt_frame,
514
+ creatures=self._creatures.entries,
515
+ )
516
+ if picked is not None:
517
+ self._world.audio_router.play_sfx("sfx_ui_bonus")
518
+ self._close_perk_menu()
519
+
520
+ def _draw_perk_prompt(self) -> None:
521
+ if self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3:
522
+ return
523
+ if not any(player.health > 0.0 for player in self._world.players):
524
+ return
525
+ pending = int(self._state.perk_selection.pending_count)
526
+ if pending <= 0:
527
+ return
528
+ label = self._perk_prompt_label()
529
+ if not label:
530
+ return
531
+
532
+ alpha = float(self._perk_prompt_timer_ms) / PERK_PROMPT_MAX_TIMER_MS
533
+ if alpha <= 1e-3:
534
+ return
535
+
536
+ hinge_x, hinge_y = self._perk_prompt_hinge()
537
+ # Prompt swings counter-clockwise; raylib's Y-down makes positive rotation clockwise.
538
+ rot_deg = -(1.0 - alpha) * 90.0
539
+ tint = rl.Color(255, 255, 255, int(255 * alpha))
540
+
541
+ text_w = float(self._ui_text_width(label, UI_TEXT_SCALE))
542
+ x = float(rl.get_screen_width()) - PERK_PROMPT_TEXT_MARGIN_X - text_w
543
+ y = hinge_y + PERK_PROMPT_TEXT_OFFSET_Y
544
+ color = rl.Color(UI_TEXT_COLOR.r, UI_TEXT_COLOR.g, UI_TEXT_COLOR.b, int(255 * alpha))
545
+ draw_ui_text(self._small, label, x, y, scale=UI_TEXT_SCALE, color=color)
546
+
547
+ if self._perk_menu_assets is not None and self._perk_menu_assets.menu_item is not None:
548
+ tex = self._perk_menu_assets.menu_item
549
+ bar_w = float(tex.width) * PERK_PROMPT_BAR_SCALE
550
+ bar_h = float(tex.height) * PERK_PROMPT_BAR_SCALE
551
+ local_x = (PERK_PROMPT_BAR_BASE_OFFSET_X + PERK_PROMPT_BAR_SHIFT_X) * PERK_PROMPT_BAR_SCALE
552
+ local_y = PERK_PROMPT_BAR_BASE_OFFSET_Y * PERK_PROMPT_BAR_SCALE
553
+ src = rl.Rectangle(float(tex.width), 0.0, -float(tex.width), float(tex.height))
554
+ dst = rl.Rectangle(float(hinge_x), float(hinge_y), float(bar_w), float(bar_h))
555
+ origin = rl.Vector2(float(-local_x), float(-local_y))
556
+ rl.draw_texture_pro(tex, src, dst, origin, rot_deg, tint)
557
+
558
+ if self._perk_menu_assets is not None and self._perk_menu_assets.title_level_up is not None:
559
+ tex = self._perk_menu_assets.title_level_up
560
+ local_x = PERK_PROMPT_LEVEL_UP_BASE_OFFSET_X * PERK_PROMPT_LEVEL_UP_SCALE + PERK_PROMPT_LEVEL_UP_SHIFT_X
561
+ local_y = PERK_PROMPT_LEVEL_UP_BASE_OFFSET_Y * PERK_PROMPT_LEVEL_UP_SCALE + PERK_PROMPT_LEVEL_UP_SHIFT_Y
562
+ w = PERK_PROMPT_LEVEL_UP_BASE_W * PERK_PROMPT_LEVEL_UP_SCALE
563
+ h = PERK_PROMPT_LEVEL_UP_BASE_H * PERK_PROMPT_LEVEL_UP_SCALE
564
+ pulse_alpha = (100.0 + float(int(self._perk_prompt_pulse * 155.0 / 1000.0))) / 255.0
565
+ pulse_alpha = max(0.0, min(1.0, pulse_alpha))
566
+ label_alpha = max(0.0, min(1.0, alpha * pulse_alpha))
567
+ pulse_tint = rl.Color(255, 255, 255, int(255 * label_alpha))
568
+ src = rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height))
569
+ dst = rl.Rectangle(float(hinge_x), float(hinge_y), float(w), float(h))
570
+ origin = rl.Vector2(float(-local_x), float(-local_y))
571
+ rl.draw_texture_pro(tex, src, dst, origin, rot_deg, pulse_tint)
572
+ if label_alpha > 0.0:
573
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
574
+ rl.draw_texture_pro(tex, src, dst, origin, rot_deg, pulse_tint)
575
+ rl.end_blend_mode()
576
+
577
+ def _draw_perk_menu(self) -> None:
578
+ menu_t = _clamp(self._perk_menu_timeline_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
579
+ if menu_t <= 1e-3:
580
+ return
581
+ if self._perk_menu_assets is None:
582
+ return
583
+
584
+ perk_state = self._state.perk_selection
585
+ players = self._world.players
586
+ choices = perk_selection_current_choices(
587
+ self._state,
588
+ players,
589
+ perk_state,
590
+ game_mode=int(GameMode.QUESTS),
591
+ player_count=len(players),
592
+ )
593
+ if not choices:
594
+ return
595
+
596
+ screen_w = float(rl.get_screen_width())
597
+ screen_h = float(rl.get_screen_height())
598
+ scale = ui_scale(screen_w, screen_h)
599
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
600
+ slide_x = (menu_t - 1.0) * (self._perk_ui_layout.panel_w * scale)
601
+
602
+ master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
603
+ expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
604
+ computed = perk_menu_compute_layout(
605
+ self._perk_ui_layout,
606
+ screen_w=screen_w,
607
+ origin_x=origin_x + slide_x,
608
+ origin_y=origin_y,
609
+ scale=scale,
610
+ choice_count=len(choices),
611
+ expert_owned=expert_owned,
612
+ master_owned=master_owned,
613
+ )
614
+
615
+ panel_tex = self._perk_menu_assets.menu_panel
616
+ if panel_tex is not None:
617
+ draw_menu_panel(panel_tex, dst=computed.panel)
618
+
619
+ title_tex = self._perk_menu_assets.title_pick_perk
620
+ if title_tex is not None:
621
+ src = rl.Rectangle(0.0, 0.0, float(title_tex.width), float(title_tex.height))
622
+ rl.draw_texture_pro(title_tex, src, computed.title, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
623
+
624
+ sponsor = None
625
+ if master_owned:
626
+ sponsor = "extra perks sponsored by the Perk Master"
627
+ elif expert_owned:
628
+ sponsor = "extra perk sponsored by the Perk Expert"
629
+ if sponsor:
630
+ draw_ui_text(self._small, sponsor, computed.sponsor_x, computed.sponsor_y, scale=scale, color=UI_SPONSOR_COLOR)
631
+
632
+ mouse = self._ui_mouse_pos()
633
+ fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
634
+ for idx, perk_id in enumerate(choices):
635
+ label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
636
+ item_x = computed.list_x
637
+ item_y = computed.list_y + float(idx) * computed.list_step_y
638
+ rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
639
+ hovered = rl.check_collision_point_rec(mouse, rect) or (idx == self._perk_menu_selected)
640
+ draw_menu_item(self._small, label, x=item_x, y=item_y, scale=scale, hovered=hovered)
641
+
642
+ selected = choices[self._perk_menu_selected]
643
+ desc = perk_display_description(int(selected), fx_toggle=fx_toggle)
644
+ desc_x = float(computed.desc.x)
645
+ desc_y = float(computed.desc.y)
646
+ desc_w = float(computed.desc.width)
647
+ desc_h = float(computed.desc.height)
648
+ desc_scale = scale * 0.85
649
+ desc_lines = wrap_ui_text(self._small, desc, max_width=desc_w, scale=desc_scale)
650
+ line_h = float(self._small.cell_size * desc_scale) if self._small is not None else float(20 * desc_scale)
651
+ y = desc_y
652
+ for line in desc_lines:
653
+ if y + line_h > desc_y + desc_h:
654
+ break
655
+ draw_ui_text(self._small, line, desc_x, y, scale=desc_scale, color=UI_TEXT_COLOR)
656
+ y += line_h
657
+
658
+ cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
659
+ cancel_x = computed.cancel_x
660
+ button_y = computed.cancel_y
661
+ button_draw(self._perk_menu_assets, self._small, self._perk_cancel_button, x=cancel_x, y=button_y, width=cancel_w, scale=scale)
662
+
277
663
  def update(self, dt: float) -> None:
278
664
  self._update_audio(dt)
279
665
 
280
- dt_frame = self._tick_frame(dt)[0]
281
- dt_ms = float(dt_frame * 1000.0)
666
+ dt_frame, dt_ui_ms = self._tick_frame(dt)
282
667
  self._handle_input()
283
668
 
284
669
  if self.close_requested:
285
670
  return
286
671
 
287
672
  any_alive = any(player.health > 0.0 for player in self._world.players)
288
- dt_world = 0.0 if self._paused or (not any_alive) else dt_frame
673
+ perk_pending = int(self._state.perk_selection.pending_count) > 0 and any_alive
674
+
675
+ self._perk_prompt_hover = False
676
+ if self._perk_menu_open:
677
+ self._perk_menu_handle_input(dt_frame, dt_ui_ms)
678
+
679
+ perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
680
+
681
+ if (not perk_menu_active) and perk_pending and (not self._paused):
682
+ label = self._perk_prompt_label()
683
+ if label:
684
+ rect = self._perk_prompt_rect(label)
685
+ self._perk_prompt_hover = rl.check_collision_point_rec(self._ui_mouse_pos(), rect)
686
+
687
+ keybinds = config_keybinds(self._config)
688
+ if not keybinds:
689
+ keybinds = (0x11, 0x1F, 0x1E, 0x20, 0x100)
690
+ _up_key, _down_key, _left_key, _right_key, fire_key = player_move_fire_binds(keybinds, 0)
691
+
692
+ pick_key = 0x101
693
+ if self._config is not None:
694
+ pick_key = int(self._config.data.get("keybind_pick_perk", pick_key) or pick_key)
695
+
696
+ if input_code_is_pressed(pick_key) and (not input_code_is_down(fire_key)):
697
+ self._perk_prompt_pulse = 1000.0
698
+ self._open_perk_menu()
699
+ elif self._perk_prompt_hover and input_code_is_pressed(fire_key):
700
+ self._perk_prompt_pulse = 1000.0
701
+ self._open_perk_menu()
702
+
703
+ perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
704
+
705
+ if not self._paused:
706
+ pulse_delta = dt_ui_ms * (6.0 if self._perk_prompt_hover else -2.0)
707
+ self._perk_prompt_pulse = _clamp(self._perk_prompt_pulse + pulse_delta, 0.0, 1000.0)
708
+
709
+ prompt_active = perk_pending and (not perk_menu_active) and (not self._paused)
710
+ if prompt_active:
711
+ self._perk_prompt_timer_ms = _clamp(self._perk_prompt_timer_ms + dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
712
+ else:
713
+ self._perk_prompt_timer_ms = _clamp(self._perk_prompt_timer_ms - dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
714
+
715
+ if self._perk_menu_open:
716
+ self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
717
+ else:
718
+ self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms - dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
719
+
720
+ dt_world = 0.0 if self._paused or (not any_alive) or perk_menu_active else dt_frame
289
721
  if dt_world <= 0.0:
290
722
  return
291
723
 
292
- self._quest.quest_name_timer_ms += dt_ms
724
+ self._quest.quest_name_timer_ms += dt_world * 1000.0
293
725
 
294
726
  input_state = self._build_input()
295
727
  self._world.update(
@@ -405,11 +837,17 @@ class QuestMode(BaseGameplayMode):
405
837
  self.close_requested = True
406
838
 
407
839
  def draw(self) -> None:
408
- self._world.draw(draw_aim_indicators=True)
840
+ perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
841
+ self._world.draw(draw_aim_indicators=not perk_menu_active)
409
842
  self._draw_screen_fade()
410
843
 
411
844
  hud_bottom = 0.0
412
- if self._hud_assets is not None:
845
+ if (not perk_menu_active) and self._hud_assets is not None:
846
+ total = int(self._quest.total_spawn_count)
847
+ kills = int(self._creatures.kill_count)
848
+ quest_progress_ratio = float(kills) / float(total) if total > 0 else None
849
+ hud_flags = hud_flags_for_game_mode(self._config_game_mode_id())
850
+ self._draw_target_health_bar()
413
851
  hud_bottom = draw_hud_overlay(
414
852
  self._hud_assets,
415
853
  player=self._player,
@@ -418,31 +856,14 @@ class QuestMode(BaseGameplayMode):
418
856
  elapsed_ms=float(self._quest.spawn_timeline_ms),
419
857
  font=self._small,
420
858
  frame_dt_ms=self._last_dt_ms,
421
- show_xp=False,
422
- show_time=True,
859
+ show_health=hud_flags.show_health,
860
+ show_weapon=hud_flags.show_weapon,
861
+ show_xp=hud_flags.show_xp,
862
+ show_time=hud_flags.show_time,
863
+ show_quest_hud=hud_flags.show_quest_hud,
864
+ quest_progress_ratio=quest_progress_ratio,
865
+ small_indicators=self._hud_small_indicators(),
423
866
  )
424
- total = int(self._quest.total_spawn_count)
425
- if total > 0:
426
- kills = int(self._creatures.kill_count)
427
- ratio = max(0.0, min(1.0, float(kills) / float(total)))
428
- scale = hud_ui_scale(float(rl.get_screen_width()), float(rl.get_screen_height()))
429
- bar_x = 255.0 * scale
430
- bar_y = 30.0 * scale
431
- bar_w = 120.0 * scale
432
- bar_h = 6.0 * scale
433
- bg = rl.Color(40, 40, 48, 200)
434
- fg = rl.Color(220, 220, 220, 240)
435
- rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), bg)
436
- inner_w = max(0.0, bar_w - 2.0 * scale)
437
- inner_h = max(0.0, bar_h - 2.0 * scale)
438
- rl.draw_rectangle(
439
- int(bar_x + scale),
440
- int(bar_y + scale),
441
- int(inner_w * ratio),
442
- int(inner_h),
443
- fg,
444
- )
445
- self._draw_ui_text(f"{kills}/{total}", bar_x + bar_w + 8.0 * scale, bar_y - 3.0 * scale, rl.Color(220, 220, 220, 255), scale=0.8 * scale)
446
867
 
447
868
  self._draw_quest_title()
448
869
 
@@ -455,15 +876,21 @@ class QuestMode(BaseGameplayMode):
455
876
  warn = "Missing HUD assets: " + ", ".join(self._hud_missing)
456
877
  self._draw_ui_text(warn, 24.0, warn_y, rl.Color(240, 80, 80, 255), scale=0.8)
457
878
 
458
- self._draw_aim_cursor()
459
- if self._paused:
879
+ self._draw_perk_prompt()
880
+ self._draw_perk_menu()
881
+
882
+ if perk_menu_active:
883
+ self._draw_game_cursor()
884
+ elif self._paused:
460
885
  self._draw_game_cursor()
461
886
  x = 18.0
462
887
  y = max(18.0, hud_bottom + 10.0)
463
- self._draw_ui_text("paused (TAB)", x, y, rl.Color(140, 140, 140, 255))
888
+ self._draw_ui_text("paused (TAB)", x, y, UI_HINT_COLOR)
889
+ else:
890
+ self._draw_aim_cursor()
464
891
 
465
892
  def _draw_game_cursor(self) -> None:
466
- assets = self._ui_assets
893
+ assets = self._perk_menu_assets
467
894
  cursor_tex = assets.cursor if assets is not None else None
468
895
  draw_menu_cursor(
469
896
  self._world.particles_texture,
@@ -474,7 +901,7 @@ class QuestMode(BaseGameplayMode):
474
901
  )
475
902
 
476
903
  def _draw_aim_cursor(self) -> None:
477
- assets = self._ui_assets
904
+ assets = self._perk_menu_assets
478
905
  aim_tex = assets.aim if assets is not None else None
479
906
  draw_aim_cursor(
480
907
  self._world.particles_texture,
@@ -7,6 +7,7 @@ import pyray as rl
7
7
 
8
8
  from grim.assets import PaqTextureCache
9
9
  from grim.audio import AudioState
10
+ from grim.console import ConsoleState
10
11
  from grim.config import CrimsonConfig
11
12
  from grim.view import ViewContext
12
13
 
@@ -16,7 +17,7 @@ from ..gameplay import PlayerInput, most_used_weapon_id_for_player, weapon_assig
16
17
  from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
17
18
  from ..persistence.highscores import HighScoreRecord
18
19
  from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
19
- from ..ui.hud import draw_hud_overlay
20
+ from ..ui.hud import draw_hud_overlay, hud_flags_for_game_mode
20
21
  from ..ui.perk_menu import load_perk_menu_assets
21
22
  from .base_gameplay_mode import BaseGameplayMode
22
23
 
@@ -42,6 +43,7 @@ class RushMode(BaseGameplayMode):
42
43
  *,
43
44
  texture_cache: PaqTextureCache | None = None,
44
45
  config: CrimsonConfig | None = None,
46
+ console: ConsoleState | None = None,
45
47
  audio: AudioState | None = None,
46
48
  audio_rng: random.Random | None = None,
47
49
  ) -> None:
@@ -54,6 +56,7 @@ class RushMode(BaseGameplayMode):
54
56
  hardcore=False,
55
57
  texture_cache=texture_cache,
56
58
  config=config,
59
+ console=console,
57
60
  audio=audio,
58
61
  audio_rng=audio_rng,
59
62
  )
@@ -255,6 +258,8 @@ class RushMode(BaseGameplayMode):
255
258
 
256
259
  hud_bottom = 0.0
257
260
  if (not self._game_over_active) and self._hud_assets is not None:
261
+ hud_flags = hud_flags_for_game_mode(self._config_game_mode_id())
262
+ self._draw_target_health_bar()
258
263
  hud_bottom = draw_hud_overlay(
259
264
  self._hud_assets,
260
265
  player=self._player,
@@ -263,8 +268,12 @@ class RushMode(BaseGameplayMode):
263
268
  elapsed_ms=self._rush.elapsed_ms,
264
269
  font=self._small,
265
270
  frame_dt_ms=self._last_dt_ms,
266
- show_xp=False,
267
- show_time=True,
271
+ show_health=hud_flags.show_health,
272
+ show_weapon=hud_flags.show_weapon,
273
+ show_xp=hud_flags.show_xp,
274
+ show_time=hud_flags.show_time,
275
+ show_quest_hud=hud_flags.show_quest_hud,
276
+ small_indicators=self._hud_small_indicators(),
268
277
  )
269
278
 
270
279
  if not self._game_over_active: