crimsonland 0.1.0.dev10__py3-none-any.whl → 0.1.0.dev12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
crimson/gameplay.py CHANGED
@@ -472,7 +472,7 @@ class BonusPool:
472
472
  player,
473
473
  BonusId(entry.bonus_id),
474
474
  amount=entry.amount,
475
- origin=player,
475
+ origin=entry,
476
476
  creatures=creatures,
477
477
  players=players,
478
478
  apply_creature_damage=apply_creature_damage,
@@ -585,7 +585,7 @@ def _creature_find_in_radius(creatures: list[_CreatureForPerks], *, pos_x: float
585
585
  continue
586
586
 
587
587
  dist = math.hypot(float(creature.x) - pos_x, float(creature.y) - pos_y) - radius
588
- threshold = float(creature.size) * 0.142857149 + 3.0
588
+ threshold = float(creature.size) * 0.14285715 + 3.0
589
589
  if threshold < dist:
590
590
  continue
591
591
  if float(creature.hitbox_size) < 5.0:
@@ -1511,9 +1511,9 @@ def player_start_reload(player: PlayerState, state: GameplayState) -> None:
1511
1511
  player.reload_active = True
1512
1512
 
1513
1513
  if perk_active(player, PerkId.FASTLOADER):
1514
- reload_time *= 0.69999999
1514
+ reload_time *= 0.7
1515
1515
  if state.bonuses.weapon_power_up > 0.0:
1516
- reload_time *= 0.60000002
1516
+ reload_time *= 0.6
1517
1517
 
1518
1518
  player.reload_timer = max(0.0, reload_time)
1519
1519
  player.reload_timer_max = player.reload_timer
@@ -1618,7 +1618,14 @@ def _perk_update_fire_cough(player: PlayerState, dt: float, state: GameplayState
1618
1618
  state.perk_intervals.fire_cough = float(state.rng.rand() % 4) + 2.0
1619
1619
 
1620
1620
 
1621
- def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float, state: GameplayState) -> None:
1621
+ def player_fire_weapon(
1622
+ player: PlayerState,
1623
+ input_state: PlayerInput,
1624
+ dt: float,
1625
+ state: GameplayState,
1626
+ *,
1627
+ detail_preset: int = 5,
1628
+ ) -> None:
1622
1629
  dt = float(dt)
1623
1630
 
1624
1631
  weapon_id = int(player.weapon_id)
@@ -1689,6 +1696,20 @@ def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float,
1689
1696
  aim_y = float(input_state.aim_y)
1690
1697
  dx = aim_x - float(player.pos_x)
1691
1698
  dy = aim_y - float(player.pos_y)
1699
+ aim_heading = math.atan2(dy, dx) + math.pi / 2.0
1700
+
1701
+ muzzle_dir = (aim_heading - math.pi / 2.0) - 0.150915
1702
+ muzzle_shell_x = player.pos_x + math.cos(muzzle_dir) * 16.0
1703
+ muzzle_shell_y = player.pos_y + math.sin(muzzle_dir) * 16.0
1704
+ state.effects.spawn_shell_casing(
1705
+ pos_x=muzzle_shell_x,
1706
+ pos_y=muzzle_shell_y,
1707
+ aim_heading=aim_heading,
1708
+ weapon_flags=int(weapon.flags or 0),
1709
+ rand=state.rng.rand,
1710
+ detail_preset=int(detail_preset),
1711
+ )
1712
+
1692
1713
  dist = math.hypot(dx, dy)
1693
1714
  max_offset = dist * float(player.spread_heat) * 0.5
1694
1715
  dir_angle = float(int(state.rng.rand()) & 0x1FF) * (math.tau / 512.0)
@@ -1885,7 +1906,15 @@ def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float,
1885
1906
  player_start_reload(player, state)
1886
1907
 
1887
1908
 
1888
- def player_update(player: PlayerState, input_state: PlayerInput, dt: float, state: GameplayState, *, world_size: float = 1024.0) -> None:
1909
+ def player_update(
1910
+ player: PlayerState,
1911
+ input_state: PlayerInput,
1912
+ dt: float,
1913
+ state: GameplayState,
1914
+ *,
1915
+ detail_preset: int = 5,
1916
+ world_size: float = 1024.0,
1917
+ ) -> None:
1889
1918
  """Port of `player_update` (0x004136b0) for the rewrite runtime."""
1890
1919
 
1891
1920
  if dt <= 0.0:
@@ -2048,7 +2077,7 @@ def player_update(player: PlayerState, input_state: PlayerInput, dt: float, stat
2048
2077
  elif player.reload_timer == 0.0:
2049
2078
  player_start_reload(player, state)
2050
2079
 
2051
- player_fire_weapon(player, input_state, dt, state)
2080
+ player_fire_weapon(player, input_state, dt, state, detail_preset=int(detail_preset))
2052
2081
 
2053
2082
  while player.move_phase > 14.0:
2054
2083
  player.move_phase -= 14.0
@@ -2458,7 +2487,7 @@ def bonus_telekinetic_update(
2458
2487
  player,
2459
2488
  BonusId(int(entry.bonus_id)),
2460
2489
  amount=int(entry.amount),
2461
- origin=player,
2490
+ origin=entry,
2462
2491
  creatures=creatures,
2463
2492
  players=players,
2464
2493
  apply_creature_damage=apply_creature_damage,
@@ -283,6 +283,9 @@ class BaseGameplayMode:
283
283
  self._action = None
284
284
  return action
285
285
 
286
+ def draw_pause_background(self) -> None:
287
+ self._world.draw(draw_aim_indicators=False)
288
+
286
289
  def _draw_screen_fade(self) -> None:
287
290
  fade_alpha = 0.0
288
291
  if self._screen_fade is not None:
@@ -306,7 +306,8 @@ class QuestMode(BaseGameplayMode):
306
306
  self._paused = not self._paused
307
307
 
308
308
  if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
309
- self.close_requested = True
309
+ self._action = "open_pause_menu"
310
+ return
310
311
 
311
312
  def _build_input(self):
312
313
  keybinds = config_keybinds(self._config)
@@ -517,6 +518,42 @@ class QuestMode(BaseGameplayMode):
517
518
  self._world.audio_router.play_sfx("sfx_ui_bonus")
518
519
  self._close_perk_menu()
519
520
 
521
+ def _close_failed_run(self) -> None:
522
+ if self._outcome is None:
523
+ fired = 0
524
+ hit = 0
525
+ try:
526
+ fired = int(self._state.shots_fired[int(self._player.index)])
527
+ hit = int(self._state.shots_hit[int(self._player.index)])
528
+ except Exception:
529
+ fired = 0
530
+ hit = 0
531
+ fired = max(0, int(fired))
532
+ hit = max(0, min(int(hit), fired))
533
+ most_used_weapon_id = most_used_weapon_id_for_player(
534
+ self._state,
535
+ player_index=int(self._player.index),
536
+ fallback_weapon_id=int(self._player.weapon_id),
537
+ )
538
+ player2_health = None
539
+ if len(self._world.players) >= 2:
540
+ player2_health = float(self._world.players[1].health)
541
+ self._outcome = QuestRunOutcome(
542
+ kind="failed",
543
+ level=str(self._quest.level),
544
+ base_time_ms=int(self._quest.spawn_timeline_ms),
545
+ player_health=float(self._player.health),
546
+ player2_health=player2_health,
547
+ pending_perk_count=int(self._state.perk_selection.pending_count),
548
+ experience=int(self._player.experience),
549
+ kill_count=int(self._creatures.kill_count),
550
+ weapon_id=int(self._player.weapon_id),
551
+ shots_fired=fired,
552
+ shots_hit=hit,
553
+ most_used_weapon_id=int(most_used_weapon_id),
554
+ )
555
+ self.close_requested = True
556
+
520
557
  def _draw_perk_prompt(self) -> None:
521
558
  if self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3:
522
559
  return
@@ -614,7 +651,8 @@ class QuestMode(BaseGameplayMode):
614
651
 
615
652
  panel_tex = self._perk_menu_assets.menu_panel
616
653
  if panel_tex is not None:
617
- draw_menu_panel(panel_tex, dst=computed.panel)
654
+ fx_detail = bool(int(self._config.data.get("fx_detail_0", 0) or 0)) if self._config is not None else False
655
+ draw_menu_panel(panel_tex, dst=computed.panel, shadow=fx_detail)
618
656
 
619
657
  title_tex = self._perk_menu_assets.title_pick_perk
620
658
  if title_tex is not None:
@@ -665,6 +703,8 @@ class QuestMode(BaseGameplayMode):
665
703
 
666
704
  dt_frame, dt_ui_ms = self._tick_frame(dt)
667
705
  self._handle_input()
706
+ if self._action == "open_pause_menu":
707
+ return
668
708
 
669
709
  if self.close_requested:
670
710
  return
@@ -719,6 +759,8 @@ class QuestMode(BaseGameplayMode):
719
759
 
720
760
  dt_world = 0.0 if self._paused or (not any_alive) or perk_menu_active else dt_frame
721
761
  if dt_world <= 0.0:
762
+ if not any(player.health > 0.0 for player in self._world.players):
763
+ self._close_failed_run()
722
764
  return
723
765
 
724
766
  self._quest.quest_name_timer_ms += dt_world * 1000.0
@@ -734,40 +776,7 @@ class QuestMode(BaseGameplayMode):
734
776
 
735
777
  any_alive_after = any(player.health > 0.0 for player in self._world.players)
736
778
  if not any_alive_after:
737
- if self._outcome is None:
738
- fired = 0
739
- hit = 0
740
- try:
741
- fired = int(self._state.shots_fired[int(self._player.index)])
742
- hit = int(self._state.shots_hit[int(self._player.index)])
743
- except Exception:
744
- fired = 0
745
- hit = 0
746
- fired = max(0, int(fired))
747
- hit = max(0, min(int(hit), fired))
748
- most_used_weapon_id = most_used_weapon_id_for_player(
749
- self._state,
750
- player_index=int(self._player.index),
751
- fallback_weapon_id=int(self._player.weapon_id),
752
- )
753
- player2_health = None
754
- if len(self._world.players) >= 2:
755
- player2_health = float(self._world.players[1].health)
756
- self._outcome = QuestRunOutcome(
757
- kind="failed",
758
- level=str(self._quest.level),
759
- base_time_ms=int(self._quest.spawn_timeline_ms),
760
- player_health=float(self._player.health),
761
- player2_health=player2_health,
762
- pending_perk_count=int(self._state.perk_selection.pending_count),
763
- experience=int(self._player.experience),
764
- kill_count=int(self._creatures.kill_count),
765
- weapon_id=int(self._player.weapon_id),
766
- shots_fired=fired,
767
- shots_hit=hit,
768
- most_used_weapon_id=int(most_used_weapon_id),
769
- )
770
- self.close_requested = True
779
+ self._close_failed_run()
771
780
  return
772
781
 
773
782
  creatures_none_active = not bool(self._creatures.iter_active())
@@ -95,7 +95,8 @@ class RushMode(BaseGameplayMode):
95
95
  self._paused = not self._paused
96
96
 
97
97
  if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
98
- self.close_requested = True
98
+ self._action = "open_pause_menu"
99
+ return
99
100
 
100
101
  def _build_input(self) -> PlayerInput:
101
102
  keybinds = config_keybinds(self._config)
@@ -174,6 +175,8 @@ class RushMode(BaseGameplayMode):
174
175
 
175
176
  dt_frame = self._tick_frame(dt)[0]
176
177
  self._handle_input()
178
+ if self._action == "open_pause_menu":
179
+ return
177
180
 
178
181
  if self._game_over_active:
179
182
  record = self._game_over_record
@@ -186,7 +186,8 @@ class SurvivalMode(BaseGameplayMode):
186
186
  survival_check_level_up(self._player, self._state.perk_selection)
187
187
 
188
188
  if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
189
- self.close_requested = True
189
+ self._action = "open_pause_menu"
190
+ return
190
191
 
191
192
  def _build_input(self) -> PlayerInput:
192
193
  keybinds = config_keybinds(self._config)
@@ -441,6 +442,8 @@ class SurvivalMode(BaseGameplayMode):
441
442
  dt_frame, dt_ui_ms = self._tick_frame(dt)
442
443
  self._cursor_time += dt_frame
443
444
  self._handle_input()
445
+ if self._action == "open_pause_menu":
446
+ return
444
447
 
445
448
  if self._game_over_active:
446
449
  record = self._game_over_record
@@ -668,7 +671,8 @@ class SurvivalMode(BaseGameplayMode):
668
671
 
669
672
  panel_tex = self._perk_menu_assets.menu_panel
670
673
  if panel_tex is not None:
671
- draw_menu_panel(panel_tex, dst=computed.panel)
674
+ fx_detail = bool(int(self._config.data.get("fx_detail_0", 0) or 0)) if self._config is not None else False
675
+ draw_menu_panel(panel_tex, dst=computed.panel, shadow=fx_detail)
672
676
 
673
677
  title_tex = self._perk_menu_assets.title_pick_perk
674
678
  if title_tex is not None:
@@ -143,7 +143,8 @@ class TutorialMode(BaseGameplayMode):
143
143
  self._paused = not self._paused
144
144
 
145
145
  if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
146
- self.close_requested = True
146
+ self._action = "open_pause_menu"
147
+ return
147
148
 
148
149
  def _build_input(self) -> PlayerInput:
149
150
  keybinds = config_keybinds(self._config)
@@ -346,6 +347,8 @@ class TutorialMode(BaseGameplayMode):
346
347
  dt_frame, dt_ui_ms = self._tick_frame(dt, clamp_cursor_pulse=True)
347
348
 
348
349
  self._handle_input()
350
+ if self._action == "open_pause_menu":
351
+ return
349
352
  if self.close_requested:
350
353
  return
351
354
 
@@ -605,7 +608,8 @@ class TutorialMode(BaseGameplayMode):
605
608
 
606
609
  panel_tex = assets.menu_panel
607
610
  if panel_tex is not None:
608
- draw_menu_panel(panel_tex, dst=computed.panel)
611
+ fx_detail = bool(int(self._config.data.get("fx_detail_0", 0) or 0)) if self._config is not None else False
612
+ draw_menu_panel(panel_tex, dst=computed.panel, shadow=fx_detail)
609
613
 
610
614
  title_tex = assets.title_pick_perk
611
615
  if title_tex is not None:
@@ -120,7 +120,8 @@ class TypoShooterMode(BaseGameplayMode):
120
120
  self._paused = not self._paused
121
121
 
122
122
  if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
123
- self.close_requested = True
123
+ self._action = "open_pause_menu"
124
+ return
124
125
 
125
126
  def _active_mask(self) -> list[bool]:
126
127
  return [bool(entry.active) for entry in self._creatures.entries]
@@ -257,6 +258,8 @@ class TypoShooterMode(BaseGameplayMode):
257
258
 
258
259
  dt_frame = self._tick_frame(dt)[0]
259
260
  self._handle_input()
261
+ if self._action == "open_pause_menu":
262
+ return
260
263
 
261
264
  if self._game_over_active:
262
265
  self._update_game_over_ui(dt)
@@ -239,7 +239,9 @@ def scores_path_for_mode(
239
239
  # Native `highscore_build_path` uses `questhc*.hi` when hardcore is OFF,
240
240
  # and `quest*.hi` when hardcore is ON.
241
241
  prefix = "quest" if hardcore else "questhc"
242
- return root / f"{prefix}{int(quest_stage_major)}_{int(quest_stage_minor)}.hi"
242
+ major = int(quest_stage_major)
243
+ minor = int(quest_stage_minor)
244
+ return root / f"{prefix}{major}_{minor}.hi"
243
245
  return root / "unknown.hi"
244
246
 
245
247
 
@@ -272,7 +274,9 @@ def scores_path_for_config(base_dir: Path, config: CrimsonConfig, *, quest_stage
272
274
  # Native `highscore_build_path` uses `questhc*.hi` when hardcore is OFF,
273
275
  # and `quest*.hi` when hardcore is ON.
274
276
  prefix = "quest" if hardcore else "questhc"
275
- return root / f"{prefix}{int(quest_stage_major)}_{int(quest_stage_minor)}.hi"
277
+ major = int(quest_stage_major)
278
+ minor = int(quest_stage_minor)
279
+ return root / f"{prefix}{major}_{minor}.hi"
276
280
  return root / "unknown.hi"
277
281
 
278
282
 
@@ -947,7 +947,7 @@ class WorldRenderer:
947
947
  if float(getattr(creature, "hitbox_size", 0.0)) <= 5.0:
948
948
  continue
949
949
  d = math.hypot(float(creature.x) - pos_x, float(creature.y) - pos_y)
950
- threshold = float(creature.size) * 0.142857149 + 3.0
950
+ threshold = float(creature.size) * 0.14285715 + 3.0
951
951
  if d - radius < threshold:
952
952
  targets.append(creature)
953
953
 
@@ -290,7 +290,14 @@ class WorldState:
290
290
 
291
291
  for idx, player in enumerate(self.players):
292
292
  input_state = inputs[idx] if idx < len(inputs) else PlayerInput()
293
- player_update(player, input_state, dt, self.state, world_size=float(world_size))
293
+ player_update(
294
+ player,
295
+ input_state,
296
+ dt,
297
+ self.state,
298
+ detail_preset=int(detail_preset),
299
+ world_size=float(world_size),
300
+ )
294
301
 
295
302
  if dt > 0.0:
296
303
  self._advance_creature_anim(dt)
crimson/typo/spawns.py CHANGED
@@ -47,9 +47,9 @@ def tick_typo_spawns(
47
47
  y = math.cos(t) * 256.0 + float(world_height) * 0.5
48
48
 
49
49
  tint_t = float(elapsed_ms + 1)
50
- tint_r = _clamp(tint_t * 0.0000083333334 + 0.30000001, 0.0, 1.0)
51
- tint_g = _clamp(tint_t * 10000.0 + 0.30000001, 0.0, 1.0)
52
- tint_b = _clamp(math.sin(tint_t * 0.0001) + 0.30000001, 0.0, 1.0)
50
+ tint_r = _clamp(tint_t * 0.0000083333334 + 0.3, 0.0, 1.0)
51
+ tint_g = _clamp(tint_t * 10000.0 + 0.3, 0.0, 1.0)
52
+ tint_b = _clamp(math.sin(tint_t * 0.0001) + 0.3, 0.0, 1.0)
53
53
  tint = (tint_r, tint_g, tint_b, 1.0)
54
54
 
55
55
  spawns.append(
@@ -70,4 +70,3 @@ def tick_typo_spawns(
70
70
  )
71
71
 
72
72
  return cooldown, spawns
73
-
@@ -66,13 +66,14 @@ class DemoTrialOverlayUi:
66
66
 
67
67
  if self._assets is None:
68
68
  cursor = cache.get_or_load("ui_cursor", "ui/ui_cursor.jaz").texture
69
- button_md = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
69
+ button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
70
+ button_md = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
70
71
  self._assets = PerkMenuAssets(
71
72
  menu_panel=None,
72
73
  title_pick_perk=None,
73
74
  title_level_up=None,
74
75
  menu_item=None,
75
- button_sm=None,
76
+ button_sm=button_sm,
76
77
  button_md=button_md,
77
78
  cursor=cursor,
78
79
  aim=None,
@@ -232,4 +233,3 @@ class DemoTrialOverlayUi:
232
233
  scale=float(scale),
233
234
  )
234
235
  cursor_draw(assets, mouse=rl.get_mouse_position(), scale=1.0, alpha=1.0)
235
-
crimson/ui/game_over.py CHANGED
@@ -31,6 +31,7 @@ from .perk_menu import (
31
31
  draw_ui_text,
32
32
  load_perk_menu_assets,
33
33
  )
34
+ from .shadow import UI_SHADOW_OFFSET, draw_ui_quad_shadow
34
35
 
35
36
 
36
37
  UI_BASE_WIDTH = 640.0
@@ -568,9 +569,24 @@ class GameOverUi:
568
569
 
569
570
  # Panel background
570
571
  if self.assets.menu_panel is not None:
571
- src = rl.Rectangle(0.0, 0.0, float(self.assets.menu_panel.width), float(self.assets.menu_panel.height))
572
+ panel_tex = self.assets.menu_panel
573
+ src = rl.Rectangle(0.0, 0.0, float(panel_tex.width), float(panel_tex.height))
572
574
  dst = rl.Rectangle(panel.x, panel.y, panel.width, panel.height)
573
- rl.draw_texture_pro(self.assets.menu_panel, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
575
+ fx_detail = bool(int(getattr(self.config, "data", {}).get("fx_detail_0", 0) or 0))
576
+ if fx_detail:
577
+ draw_ui_quad_shadow(
578
+ texture=panel_tex,
579
+ src=src,
580
+ dst=rl.Rectangle(
581
+ float(dst.x + UI_SHADOW_OFFSET),
582
+ float(dst.y + UI_SHADOW_OFFSET),
583
+ float(dst.width),
584
+ float(dst.height),
585
+ ),
586
+ origin=rl.Vector2(0.0, 0.0),
587
+ rotation_deg=0.0,
588
+ )
589
+ rl.draw_texture_pro(panel_tex, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
574
590
 
575
591
  # Banner (Reaper / Well done)
576
592
  banner = self.assets.text_reaper if banner_kind == "reaper" else self.assets.text_well_done
crimson/ui/perk_menu.py CHANGED
@@ -2,12 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
4
  from pathlib import Path
5
+ from typing import Protocol
5
6
 
6
7
  import pyray as rl
7
8
 
8
9
  from grim.assets import TextureLoader
9
10
  from grim.fonts.small import SmallFontData, draw_small_text, measure_small_text_width
10
11
 
12
+ from .shadow import UI_SHADOW_OFFSET, draw_ui_quad_shadow
13
+
11
14
 
12
15
  UI_BASE_WIDTH = 640.0
13
16
  UI_BASE_HEIGHT = 480.0
@@ -143,13 +146,32 @@ def perk_menu_compute_layout(
143
146
  )
144
147
 
145
148
 
146
- def draw_menu_panel(texture: rl.Texture, *, dst: rl.Rectangle, tint: rl.Color = rl.WHITE) -> None:
149
+ def draw_menu_panel(
150
+ texture: rl.Texture,
151
+ *,
152
+ dst: rl.Rectangle,
153
+ tint: rl.Color = rl.WHITE,
154
+ shadow: bool = False,
155
+ ) -> None:
147
156
  scale = float(dst.width) / float(texture.width)
148
157
  top_h = MENU_PANEL_SLICE_Y1 * scale
149
158
  bottom_h = (float(texture.height) - MENU_PANEL_SLICE_Y2) * scale
150
159
  mid_h = float(dst.height) - top_h - bottom_h
151
160
  if mid_h < 0.0:
152
161
  src = rl.Rectangle(0.0, 0.0, float(texture.width), float(texture.height))
162
+ if shadow:
163
+ draw_ui_quad_shadow(
164
+ texture=texture,
165
+ src=src,
166
+ dst=rl.Rectangle(
167
+ float(dst.x + UI_SHADOW_OFFSET),
168
+ float(dst.y + UI_SHADOW_OFFSET),
169
+ float(dst.width),
170
+ float(dst.height),
171
+ ),
172
+ origin=rl.Vector2(0.0, 0.0),
173
+ rotation_deg=0.0,
174
+ )
153
175
  rl.draw_texture_pro(texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, tint)
154
176
  return
155
177
 
@@ -165,6 +187,43 @@ def draw_menu_panel(texture: rl.Texture, *, dst: rl.Rectangle, tint: rl.Color =
165
187
  dst_bot = rl.Rectangle(float(dst.x), float(dst.y) + top_h + mid_h, float(dst.width), bottom_h)
166
188
 
167
189
  origin = rl.Vector2(0.0, 0.0)
190
+ if shadow:
191
+ draw_ui_quad_shadow(
192
+ texture=texture,
193
+ src=src_top,
194
+ dst=rl.Rectangle(
195
+ float(dst_top.x + UI_SHADOW_OFFSET),
196
+ float(dst_top.y + UI_SHADOW_OFFSET),
197
+ float(dst_top.width),
198
+ float(dst_top.height),
199
+ ),
200
+ origin=origin,
201
+ rotation_deg=0.0,
202
+ )
203
+ draw_ui_quad_shadow(
204
+ texture=texture,
205
+ src=src_mid,
206
+ dst=rl.Rectangle(
207
+ float(dst_mid.x + UI_SHADOW_OFFSET),
208
+ float(dst_mid.y + UI_SHADOW_OFFSET),
209
+ float(dst_mid.width),
210
+ float(dst_mid.height),
211
+ ),
212
+ origin=origin,
213
+ rotation_deg=0.0,
214
+ )
215
+ draw_ui_quad_shadow(
216
+ texture=texture,
217
+ src=src_bot,
218
+ dst=rl.Rectangle(
219
+ float(dst_bot.x + UI_SHADOW_OFFSET),
220
+ float(dst_bot.y + UI_SHADOW_OFFSET),
221
+ float(dst_bot.width),
222
+ float(dst_bot.height),
223
+ ),
224
+ origin=origin,
225
+ rotation_deg=0.0,
226
+ )
168
227
  rl.draw_texture_pro(texture, src_top, dst_top, origin, 0.0, tint)
169
228
  rl.draw_texture_pro(texture, src_mid, dst_mid, origin, 0.0, tint)
170
229
  rl.draw_texture_pro(texture, src_bot, dst_bot, origin, 0.0, tint)
@@ -198,11 +257,11 @@ def load_perk_menu_assets(assets_root: Path) -> PerkMenuAssets:
198
257
  fs_rel="ui/ui_textLevelUp.png",
199
258
  ),
200
259
  menu_item=loader.get(name="ui_menuItem", paq_rel="ui/ui_menuItem.jaz", fs_rel="ui/ui_menuItem.png"),
201
- button_sm=loader.get(name="ui_buttonSm", paq_rel="ui/ui_button_82x32.jaz", fs_rel="ui/ui_button_82x32.png"),
260
+ button_sm=loader.get(name="ui_buttonSm", paq_rel="ui/ui_button_64x32.jaz", fs_rel="ui/ui_button_64x32.png"),
202
261
  button_md=loader.get(
203
262
  name="ui_buttonMd",
204
- paq_rel="ui/ui_button_145x32.jaz",
205
- fs_rel="ui/ui_button_145x32.png",
263
+ paq_rel="ui/ui_button_128x32.jaz",
264
+ fs_rel="ui/ui_button_128x32.png",
206
265
  ),
207
266
  cursor=loader.get(name="ui_cursor", paq_rel="ui/ui_cursor.jaz", fs_rel="ui/ui_cursor.png"),
208
267
  aim=loader.get(name="ui_aim", paq_rel="ui/ui_aim.jaz", fs_rel="ui/ui_aim.png"),
@@ -281,6 +340,17 @@ def draw_menu_item(
281
340
  return float(width)
282
341
 
283
342
 
343
+ class UiButtonTextures(Protocol):
344
+ button_sm: rl.Texture | None
345
+ button_md: rl.Texture | None
346
+
347
+
348
+ @dataclass(slots=True)
349
+ class UiButtonTextureSet:
350
+ button_sm: rl.Texture | None
351
+ button_md: rl.Texture | None
352
+
353
+
284
354
  @dataclass(slots=True)
285
355
  class UiButtonState:
286
356
  label: str
@@ -343,7 +413,7 @@ def _clamp(value: float, lo: float, hi: float) -> float:
343
413
 
344
414
 
345
415
  def button_draw(
346
- assets: PerkMenuAssets,
416
+ assets: UiButtonTextures,
347
417
  font: SmallFontData | None,
348
418
  state: UiButtonState,
349
419
  *,
@@ -357,23 +427,45 @@ def button_draw(
357
427
  return
358
428
 
359
429
  if state.hover_t > 0:
360
- alpha = 0.5
430
+ # ui_button_update: highlight fill uses a hover-scaled alpha and click-biased blue tint.
431
+ # - base: (0.5, 0.5, 0.7)
432
+ # - click_anim: +0.0005 / +0.0007, clamped to 1.0 (towards white)
433
+ # - alpha: hover_anim * 0.001 * button.alpha
434
+ r = 0.5
435
+ g = 0.5
436
+ b = 0.7
361
437
  if state.press_t > 0:
362
- alpha = min(1.0, 0.5 + (float(state.press_t) * 0.0005))
363
- hl = rl.Color(255, 255, 255, int(255 * alpha * 0.25 * state.alpha))
364
- rl.draw_rectangle(int(x + 12.0 * scale), int(y + 5.0 * scale), int(width - 24.0 * scale), int(22.0 * scale), hl)
365
-
366
- tint_a = state.alpha if state.hovered else state.alpha * 0.7
367
- tint = rl.Color(255, 255, 255, int(255 * _clamp(tint_a, 0.0, 1.0)))
438
+ click_t = float(state.press_t)
439
+ g = min(1.0, 0.5 + click_t * 0.0005)
440
+ r = g
441
+ b = min(1.0, 0.7 + click_t * 0.0007)
442
+ a = float(state.hover_t) * 0.001 * state.alpha
443
+ hl = rl.Color(
444
+ int(255 * r),
445
+ int(255 * g),
446
+ int(255 * b),
447
+ int(255 * _clamp(a, 0.0, 1.0)),
448
+ )
449
+ rl.draw_rectangle(
450
+ int(x + 12.0 * scale),
451
+ int(y + 5.0 * scale),
452
+ int(width - 24.0 * scale),
453
+ int(22.0 * scale),
454
+ hl,
455
+ )
456
+
457
+ plate_tint = rl.Color(255, 255, 255, int(255 * _clamp(state.alpha, 0.0, 1.0)))
368
458
 
369
459
  src = rl.Rectangle(0.0, 0.0, float(texture.width), float(texture.height))
370
460
  dst = rl.Rectangle(float(x), float(y), float(width), float(32.0 * scale))
371
- rl.draw_texture_pro(texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, tint)
461
+ rl.draw_texture_pro(texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, plate_tint)
372
462
 
463
+ text_a = state.alpha if state.hovered else state.alpha * 0.7
464
+ text_tint = rl.Color(255, 255, 255, int(255 * _clamp(text_a, 0.0, 1.0)))
373
465
  text_w = _ui_text_width(font, state.label, scale)
374
466
  text_x = x + width * 0.5 - text_w * 0.5 + 1.0 * scale
375
467
  text_y = y + 10.0 * scale
376
- draw_ui_text(font, state.label, text_x, text_y, scale=scale, color=tint)
468
+ draw_ui_text(font, state.label, text_x, text_y, scale=scale, color=text_tint)
377
469
 
378
470
 
379
471
  def cursor_draw(assets: PerkMenuAssets, *, mouse: rl.Vector2, scale: float, alpha: float = 1.0) -> None: