crimsonland 0.1.0.dev5__py3-none-any.whl → 0.1.0.dev11__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/audio_router.py CHANGED
@@ -9,7 +9,7 @@ from grim.audio import AudioState, play_sfx, trigger_game_tune
9
9
  from .creatures.spawn import CreatureTypeId
10
10
  from .game_modes import GameMode
11
11
  from .weapon_sfx import resolve_weapon_sfx_ref
12
- from .weapons import WEAPON_BY_ID
12
+ from .weapons import WEAPON_BY_ID, WeaponId
13
13
 
14
14
  _MAX_HIT_SFX_PER_FRAME = 4
15
15
  _MAX_DEATH_SFX_PER_FRAME = 3
@@ -96,7 +96,17 @@ class AudioRouter:
96
96
  return
97
97
 
98
98
  if int(getattr(player, "shot_seq", 0)) > int(prev_shot_seq):
99
- self.play_sfx(resolve_weapon_sfx_ref(weapon.fire_sound))
99
+ if float(getattr(player, "fire_bullets_timer", 0.0)) > 0.0:
100
+ # player_update (crimsonland.exe): when Fire Bullets is active, the regular per-weapon
101
+ # shot sfx is suppressed and replaced by Fire Bullets + Plasma Minigun fire sfx.
102
+ fire_bullets = WEAPON_BY_ID.get(int(WeaponId.FIRE_BULLETS))
103
+ plasma_minigun = WEAPON_BY_ID.get(int(WeaponId.PLASMA_MINIGUN))
104
+ if fire_bullets is not None:
105
+ self.play_sfx(resolve_weapon_sfx_ref(fire_bullets.fire_sound))
106
+ if plasma_minigun is not None:
107
+ self.play_sfx(resolve_weapon_sfx_ref(plasma_minigun.fire_sound))
108
+ else:
109
+ self.play_sfx(resolve_weapon_sfx_ref(weapon.fire_sound))
100
110
 
101
111
  reload_active = bool(getattr(player, "reload_active", False))
102
112
  reload_timer = float(getattr(player, "reload_timer", 0.0))
crimson/creatures/anim.py CHANGED
@@ -28,6 +28,7 @@ _CREATURE_CORPSE_FRAMES: dict[int, int] = {
28
28
  3: 1, # spider sp1
29
29
  4: 2, # spider sp2
30
30
  5: 7, # trooper
31
+ 7: 6, # ping-pong strip corpse fallback
31
32
  }
32
33
 
33
34
 
crimson/demo.py CHANGED
@@ -475,6 +475,9 @@ class DemoView:
475
475
  player = self._world.players[idx]
476
476
  player.pos_x = float(x)
477
477
  player.pos_y = float(y)
478
+ # Keep aim anchored to the spawn position so demo aim starts stable.
479
+ player.aim_x = float(x)
480
+ player.aim_y = float(y)
478
481
  weapon_assign_player(player, int(weapon_id))
479
482
  self._demo_targets = [None] * len(self._world.players)
480
483
 
@@ -549,7 +552,8 @@ class DemoView:
549
552
 
550
553
  def _setup_variant_0(self) -> None:
551
554
  self._demo_time_limit_ms = 4000
552
- weapon_id = 12
555
+ # demo_setup_variant_0 uses weapon_id=0x0B.
556
+ weapon_id = 11
553
557
  self._setup_world_players(
554
558
  [
555
559
  (448.0, 384.0, weapon_id),
@@ -567,7 +571,8 @@ class DemoView:
567
571
 
568
572
  def _setup_variant_1(self) -> None:
569
573
  self._demo_time_limit_ms = 5000
570
- weapon_id = 6
574
+ # demo_setup_variant_1 uses weapon_id=0x05.
575
+ weapon_id = 5
571
576
  self._setup_world_players(
572
577
  [
573
578
  (490.0, 448.0, weapon_id),
@@ -586,7 +591,8 @@ class DemoView:
586
591
 
587
592
  def _setup_variant_2(self) -> None:
588
593
  self._demo_time_limit_ms = 5000
589
- weapon_id = 22
594
+ # demo_setup_variant_2 uses weapon_id=0x15.
595
+ weapon_id = 21
590
596
  self._setup_world_players([(512.0, 512.0, weapon_id)])
591
597
  y = 128
592
598
  i = 0
@@ -601,7 +607,8 @@ class DemoView:
601
607
 
602
608
  def _setup_variant_3(self) -> None:
603
609
  self._demo_time_limit_ms = 4000
604
- weapon_id = 19
610
+ # demo_setup_variant_3 uses weapon_id=0x12.
611
+ weapon_id = 18
605
612
  self._setup_world_players([(512.0, 512.0, weapon_id)])
606
613
  for idx in range(20):
607
614
  x = float(self._crand_mod(200) + 32)
@@ -690,8 +697,8 @@ class DemoView:
690
697
  rl.draw_circle(int(sx), int(sy), radius, rl.Color(200, 120, 255, 255))
691
698
  continue
692
699
  if proj.type_id == 3:
693
- t = _clamp(proj.lifetime, 0.0, 1.0)
694
- radius = proj.speed * t * 80.0
700
+ t = _clamp(proj.vel_x, 0.0, 1.0)
701
+ radius = proj.vel_y * t * 80.0
695
702
  alpha = int((1.0 - t) * 180.0)
696
703
  color = rl.Color(200, 120, 255, alpha)
697
704
  rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
@@ -881,10 +888,10 @@ class DemoView:
881
888
  def _update_world(self, dt: float) -> None:
882
889
  if not self._world.players:
883
890
  return
884
- inputs = self._build_demo_inputs()
891
+ inputs = self._build_demo_inputs(dt)
885
892
  self._world.update(dt, inputs=inputs, auto_pick_perks=False, game_mode=0, perk_progression_enabled=False)
886
893
 
887
- def _build_demo_inputs(self) -> list[PlayerInput]:
894
+ def _build_demo_inputs(self, dt: float) -> list[PlayerInput]:
888
895
  players = self._world.players
889
896
  creatures = self._world.creatures.entries
890
897
  if len(self._demo_targets) != len(players):
@@ -892,42 +899,77 @@ class DemoView:
892
899
  center_x = float(self._world.world_size) * 0.5
893
900
  center_y = float(self._world.world_size) * 0.5
894
901
 
902
+ dt = float(dt)
903
+
904
+ def _turn_towards_heading(cur: float, target: float) -> tuple[float, float]:
905
+ cur = cur % math.tau
906
+ target = target % math.tau
907
+ delta = (target - cur + math.pi) % math.tau - math.pi
908
+ diff = abs(delta)
909
+ if diff <= 1e-9:
910
+ return cur, 0.0
911
+ step = dt * diff * 5.0
912
+ cur = (cur + step) % math.tau if delta > 0.0 else (cur - step) % math.tau
913
+ return cur, diff
914
+
895
915
  inputs: list[PlayerInput] = []
896
916
  for idx, player in enumerate(players):
897
917
  target_idx = self._select_demo_target(idx, player, creatures)
898
- aim_x = center_x
899
- aim_y = center_y
900
918
  target = None
901
919
  if target_idx is not None and 0 <= target_idx < len(creatures):
902
920
  candidate = creatures[target_idx]
903
- if candidate.hp > 0.0:
921
+ if candidate.active and candidate.hp > 0.0:
904
922
  target = candidate
905
- aim_x = candidate.x
906
- aim_y = candidate.y
907
-
908
- move_x, move_y = 0.0, 0.0
909
- to_cx = center_x - player.pos_x
910
- to_cy = center_y - player.pos_y
911
- nx, ny, d = _normalize(to_cx, to_cy)
912
- if d > 120.0:
913
- move_x += nx
914
- move_y += ny
915
923
 
924
+ # Aim: ease the aim point toward the target.
925
+ aim_x = float(player.aim_x)
926
+ aim_y = float(player.aim_y)
927
+ auto_fire = False
916
928
  if target is not None:
917
- rx = player.pos_x - target.x
918
- ry = player.pos_y - target.y
919
- rnx, rny, rd = _normalize(rx, ry)
920
- if 0.0 < rd < 160.0:
921
- strength = (160.0 - rd) / 160.0
922
- move_x += rnx * (1.5 * strength)
923
- move_y += rny * (1.5 * strength)
924
-
925
- orbit_dir = -1.0 if (player.index % 2) else 1.0
926
- ox, oy, _ = _normalize(-(player.pos_y - center_y), player.pos_x - center_x)
927
- move_x += ox * 0.55 * orbit_dir
928
- move_y += oy * 0.55 * orbit_dir
929
-
930
- fire_down = target is not None
929
+ aim_dx = float(target.x) - aim_x
930
+ aim_dy = float(target.y) - aim_y
931
+ aim_dir_x, aim_dir_y, aim_dist = _normalize(aim_dx, aim_dy)
932
+ if aim_dist >= 4.0:
933
+ step = aim_dist * 6.0 * dt
934
+ aim_x += aim_dir_x * step
935
+ aim_y += aim_dir_y * step
936
+ else:
937
+ aim_x = float(target.x)
938
+ aim_y = float(target.y)
939
+ auto_fire = aim_dist < 128.0
940
+ else:
941
+ ax, ay, amag = _normalize(float(player.pos_x) - center_x, float(player.pos_y) - center_y)
942
+ if amag <= 1e-6:
943
+ ax, ay = 0.0, -1.0
944
+ aim_x = float(player.pos_x) + ax * 60.0
945
+ aim_y = float(player.pos_y) + ay * 60.0
946
+
947
+ # Movement:
948
+ # - orbit center if no target
949
+ # - chase target when near center
950
+ # - return to center when too far
951
+ if target is None:
952
+ move_dx = -(float(player.pos_y) - center_y)
953
+ move_dy = float(player.pos_x) - center_x
954
+ else:
955
+ center_dist = math.hypot(float(player.pos_x) - center_x, float(player.pos_y) - center_y)
956
+ if center_dist <= 300.0:
957
+ move_dx = float(target.x) - float(player.pos_x)
958
+ move_dy = float(target.y) - float(player.pos_y)
959
+ else:
960
+ move_dx = center_x - float(player.pos_x)
961
+ move_dy = center_y - float(player.pos_y)
962
+
963
+ desired_x, desired_y, desired_mag = _normalize(move_dx, move_dy)
964
+ if desired_mag <= 1e-6:
965
+ move_x = 0.0
966
+ move_y = 0.0
967
+ else:
968
+ desired_heading = math.atan2(desired_y, desired_x) + math.pi / 2.0
969
+ smoothed_heading, angle_diff = _turn_towards_heading(float(player.heading), desired_heading)
970
+ move_mag = max(0.001, (math.pi - angle_diff) / math.pi)
971
+ move_x = math.cos(smoothed_heading - math.pi / 2.0) * move_mag
972
+ move_y = math.sin(smoothed_heading - math.pi / 2.0) * move_mag
931
973
 
932
974
  inputs.append(
933
975
  PlayerInput(
@@ -935,8 +977,8 @@ class DemoView:
935
977
  move_y=move_y,
936
978
  aim_x=aim_x,
937
979
  aim_y=aim_y,
938
- fire_down=fire_down,
939
- fire_pressed=fire_down,
980
+ fire_down=auto_fire,
981
+ fire_pressed=auto_fire,
940
982
  reload_pressed=False,
941
983
  )
942
984
  )
crimson/effects.py CHANGED
@@ -261,7 +261,7 @@ class ParticlePool:
261
261
  entry.age = alpha
262
262
  entry.scale_x = shade
263
263
  entry.scale_y = shade
264
- entry.scale_z = shade
264
+ # Native only updates scale_x/scale_y; scale_z stays at its spawn value (1.0).
265
265
 
266
266
  alive = entry.intensity > (0.0 if style == 0 else 0.8)
267
267
  if not alive:
crimson/game.py CHANGED
@@ -789,6 +789,7 @@ class SurvivalGameView:
789
789
  ViewContext(assets_dir=state.assets_dir),
790
790
  texture_cache=state.texture_cache,
791
791
  config=state.config,
792
+ console=state.console,
792
793
  audio=state.audio,
793
794
  audio_rng=state.rng,
794
795
  )
@@ -848,6 +849,7 @@ class RushGameView:
848
849
  ViewContext(assets_dir=state.assets_dir),
849
850
  texture_cache=state.texture_cache,
850
851
  config=state.config,
852
+ console=state.console,
851
853
  audio=state.audio,
852
854
  audio_rng=state.rng,
853
855
  )
@@ -905,6 +907,7 @@ class TypoShooterGameView:
905
907
  ViewContext(assets_dir=state.assets_dir),
906
908
  texture_cache=state.texture_cache,
907
909
  config=state.config,
910
+ console=state.console,
908
911
  audio=state.audio,
909
912
  audio_rng=state.rng,
910
913
  )
@@ -962,6 +965,7 @@ class TutorialGameView:
962
965
  ViewContext(assets_dir=state.assets_dir),
963
966
  texture_cache=state.texture_cache,
964
967
  config=state.config,
968
+ console=state.console,
965
969
  audio=state.audio,
966
970
  audio_rng=state.rng,
967
971
  demo_mode_active=state.demo_enabled,
@@ -1011,6 +1015,7 @@ class QuestGameView:
1011
1015
  ViewContext(assets_dir=state.assets_dir),
1012
1016
  texture_cache=state.texture_cache,
1013
1017
  config=state.config,
1018
+ console=state.console,
1014
1019
  audio=state.audio,
1015
1020
  audio_rng=state.rng,
1016
1021
  demo_mode_active=state.demo_enabled,
crimson/gameplay.py CHANGED
@@ -159,8 +159,11 @@ class BonusHudSlot:
159
159
  bonus_id: int = 0
160
160
  label: str = ""
161
161
  icon_id: int = -1
162
+ slide_x: float = -184.0
162
163
  timer_ref: _TimerRef | None = None
163
164
  timer_ref_alt: _TimerRef | None = None
165
+ timer_value: float = 0.0
166
+ timer_value_alt: float = 0.0
164
167
 
165
168
 
166
169
  BONUS_HUD_SLOT_COUNT = 16
@@ -199,8 +202,11 @@ class BonusHudState:
199
202
  slot.bonus_id = int(bonus_id)
200
203
  slot.label = label
201
204
  slot.icon_id = int(icon_id)
205
+ slot.slide_x = -184.0
202
206
  slot.timer_ref = timer_ref
203
207
  slot.timer_ref_alt = timer_ref_alt
208
+ slot.timer_value = 0.0
209
+ slot.timer_value_alt = 0.0
204
210
 
205
211
 
206
212
  @dataclass(slots=True)
@@ -358,11 +364,49 @@ class BonusPool:
358
364
  return None
359
365
 
360
366
  rng = state.rng
361
- if rng.rand() % 9 != 1:
362
- if not any(perk_active(player, PerkId.BONUS_MAGNET) for player in players):
363
- return None
364
- if rng.rand() % 10 != 2:
365
- return None
367
+ # Native special-case: while any player has Pistol, 3/4 chance to force a Weapon drop.
368
+ if players and any(int(player.weapon_id) == int(WeaponId.PISTOL) for player in players):
369
+ if (int(rng.rand()) & 3) < 3:
370
+ entry = self.spawn_at_pos(
371
+ pos_x,
372
+ pos_y,
373
+ state=state,
374
+ players=players,
375
+ world_width=world_width,
376
+ world_height=world_height,
377
+ )
378
+ if entry is None:
379
+ return None
380
+
381
+ entry.bonus_id = int(BonusId.WEAPON)
382
+ weapon_id = int(weapon_pick_random_available(state))
383
+ entry.amount = int(weapon_id)
384
+ if weapon_id == int(WeaponId.PISTOL):
385
+ weapon_id = int(weapon_pick_random_available(state))
386
+ entry.amount = int(weapon_id)
387
+
388
+ matches = sum(1 for bonus in self._entries if bonus.bonus_id == entry.bonus_id)
389
+ if matches > 1:
390
+ self._clear_entry(entry)
391
+ return None
392
+
393
+ if entry.amount == int(WeaponId.PISTOL) or (players and perk_active(players[0], PerkId.MY_FAVOURITE_WEAPON)):
394
+ self._clear_entry(entry)
395
+ return None
396
+
397
+ return entry
398
+
399
+ base_roll = int(rng.rand())
400
+ if base_roll % 9 != 1:
401
+ allow_without_magnet = False
402
+ if players and int(players[0].weapon_id) == int(WeaponId.PISTOL):
403
+ allow_without_magnet = int(rng.rand()) % 5 == 1
404
+
405
+ if not allow_without_magnet:
406
+ if not (players and perk_active(players[0], PerkId.BONUS_MAGNET)):
407
+ return None
408
+ if int(rng.rand()) % 10 != 2:
409
+ return None
366
410
 
367
411
  entry = self.spawn_at_pos(
368
412
  pos_x,
@@ -377,11 +421,9 @@ class BonusPool:
377
421
 
378
422
  if entry.bonus_id == int(BonusId.WEAPON):
379
423
  near_sq = BONUS_WEAPON_NEAR_RADIUS * BONUS_WEAPON_NEAR_RADIUS
380
- for player in players:
381
- if _distance_sq(pos_x, pos_y, player.pos_x, player.pos_y) < near_sq:
382
- entry.bonus_id = int(BonusId.POINTS)
383
- entry.amount = 100
384
- break
424
+ if players and _distance_sq(pos_x, pos_y, players[0].pos_x, players[0].pos_y) < near_sq:
425
+ entry.bonus_id = int(BonusId.POINTS)
426
+ entry.amount = 100
385
427
 
386
428
  if entry.bonus_id != int(BonusId.POINTS):
387
429
  matches = sum(1 for bonus in self._entries if bonus.bonus_id == entry.bonus_id)
@@ -390,10 +432,9 @@ class BonusPool:
390
432
  return None
391
433
 
392
434
  if entry.bonus_id == int(BonusId.WEAPON):
393
- for player in players:
394
- if entry.amount == player.weapon_id:
395
- self._clear_entry(entry)
396
- return None
435
+ if players and entry.amount == players[0].weapon_id:
436
+ self._clear_entry(entry)
437
+ return None
397
438
 
398
439
  return entry
399
440
 
@@ -431,7 +472,7 @@ class BonusPool:
431
472
  player,
432
473
  BonusId(entry.bonus_id),
433
474
  amount=entry.amount,
434
- origin=player,
475
+ origin=entry,
435
476
  creatures=creatures,
436
477
  players=players,
437
478
  apply_creature_damage=apply_creature_damage,
@@ -933,8 +974,8 @@ def perk_apply(
933
974
  weapon_id = int(current)
934
975
  for _ in range(100):
935
976
  candidate = int(weapon_pick_random_available(state))
936
- if candidate != 0 and candidate != current:
937
- weapon_id = candidate
977
+ weapon_id = candidate
978
+ if candidate != int(WeaponId.PISTOL) and candidate != current:
938
979
  break
939
980
  weapon_assign_player(owner, weapon_id, state=state)
940
981
  return
@@ -1626,7 +1667,7 @@ def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float,
1626
1667
 
1627
1668
  shot_cooldown = float(weapon.shot_cooldown) if weapon.shot_cooldown is not None else 0.0
1628
1669
  spread_heat_base = float(weapon.spread_heat_inc) if weapon.spread_heat_inc is not None else 0.0
1629
- if is_fire_bullets and fire_bullets_weapon is not None and fire_bullets_weapon.spread_heat_inc is not None:
1670
+ if is_fire_bullets and pellet_count == 1 and fire_bullets_weapon is not None and fire_bullets_weapon.spread_heat_inc is not None:
1630
1671
  spread_heat_base = float(fire_bullets_weapon.spread_heat_inc)
1631
1672
 
1632
1673
  if is_fire_bullets and pellet_count == 1 and fire_bullets_weapon is not None:
@@ -1681,9 +1722,7 @@ def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float,
1681
1722
  shot_count = pellets
1682
1723
  meta = _projectile_meta_for_type_id(ProjectileTypeId.FIRE_BULLETS)
1683
1724
  for _ in range(pellets):
1684
- angle = shot_angle
1685
- if pellets > 1:
1686
- angle += float(int(state.rng.rand()) % 200 - 100) * 0.0015
1725
+ angle = shot_angle + float(int(state.rng.rand()) % 200 - 100) * 0.0015
1687
1726
  state.projectiles.spawn(
1688
1727
  pos_x=muzzle_x,
1689
1728
  pos_y=muzzle_y,
@@ -1890,7 +1929,9 @@ def player_update(player: PlayerState, input_state: PlayerInput, dt: float, stat
1890
1929
  raw_move_x = float(input_state.move_x)
1891
1930
  raw_move_y = float(input_state.move_y)
1892
1931
  raw_mag = math.hypot(raw_move_x, raw_move_y)
1893
- moving_input = raw_mag > 0.2
1932
+ # Demo/autoplay uses very small analog magnitudes to represent turn-in-place and
1933
+ # heading alignment slowdown; don't apply a deadzone there.
1934
+ moving_input = raw_mag > (0.0 if state.demo_mode_active else 0.2)
1894
1935
 
1895
1936
  if moving_input:
1896
1937
  inv = 1.0 / raw_mag if raw_mag > 1e-9 else 0.0
@@ -2323,8 +2364,8 @@ def bonus_apply(
2323
2364
  return
2324
2365
 
2325
2366
 
2326
- def bonus_hud_update(state: GameplayState, players: list[PlayerState]) -> None:
2327
- """Refresh HUD slots based on current timer values."""
2367
+ def bonus_hud_update(state: GameplayState, players: list[PlayerState], *, dt: float = 0.0) -> None:
2368
+ """Refresh HUD slots based on current timer values + advance slide animation."""
2328
2369
 
2329
2370
  def _timer_value(ref: _TimerRef | None) -> float:
2330
2371
  if ref is None:
@@ -2338,16 +2379,35 @@ def bonus_hud_update(state: GameplayState, players: list[PlayerState]) -> None:
2338
2379
  return float(getattr(players[idx], ref.key, 0.0) or 0.0)
2339
2380
  return 0.0
2340
2381
 
2341
- for slot in state.bonus_hud.slots:
2382
+ player_count = len(players)
2383
+ dt = max(0.0, float(dt))
2384
+
2385
+ for slot_index, slot in enumerate(state.bonus_hud.slots):
2342
2386
  if not slot.active:
2343
2387
  continue
2344
- timer = _timer_value(slot.timer_ref)
2345
- if slot.timer_ref_alt is not None:
2346
- timer = max(timer, _timer_value(slot.timer_ref_alt))
2347
- if timer <= 0.0:
2388
+ timer = max(0.0, _timer_value(slot.timer_ref))
2389
+ timer_alt = max(0.0, _timer_value(slot.timer_ref_alt)) if (slot.timer_ref_alt is not None and player_count > 1) else 0.0
2390
+ slot.timer_value = float(timer)
2391
+ slot.timer_value_alt = float(timer_alt)
2392
+
2393
+ if timer > 0.0 or timer_alt > 0.0:
2394
+ slot.slide_x += dt * 350.0
2395
+ else:
2396
+ slot.slide_x -= dt * 320.0
2397
+
2398
+ if slot.slide_x > -2.0:
2399
+ slot.slide_x = -2.0
2400
+
2401
+ if slot.slide_x < -184.0 and not any(other.active for other in state.bonus_hud.slots[slot_index + 1 :]):
2348
2402
  slot.active = False
2403
+ slot.bonus_id = 0
2404
+ slot.label = ""
2405
+ slot.icon_id = -1
2406
+ slot.slide_x = -184.0
2349
2407
  slot.timer_ref = None
2350
2408
  slot.timer_ref_alt = None
2409
+ slot.timer_value = 0.0
2410
+ slot.timer_value_alt = 0.0
2351
2411
 
2352
2412
 
2353
2413
  def bonus_telekinetic_update(
@@ -2398,7 +2458,7 @@ def bonus_telekinetic_update(
2398
2458
  player,
2399
2459
  BonusId(int(entry.bonus_id)),
2400
2460
  amount=int(entry.amount),
2401
- origin=player,
2461
+ origin=entry,
2402
2462
  creatures=creatures,
2403
2463
  players=players,
2404
2464
  apply_creature_damage=apply_creature_damage,
@@ -2462,6 +2522,6 @@ def bonus_update(
2462
2522
  state.bonuses.freeze = max(0.0, state.bonuses.freeze - dt)
2463
2523
 
2464
2524
  if update_hud:
2465
- bonus_hud_update(state, players)
2525
+ bonus_hud_update(state, players, dt=dt)
2466
2526
 
2467
2527
  return pickups
@@ -8,14 +8,17 @@ import pyray as rl
8
8
 
9
9
  from grim.assets import PaqTextureCache
10
10
  from grim.audio import AudioState, update_audio
11
+ from grim.console import ConsoleState
11
12
  from grim.config import CrimsonConfig
12
13
  from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
13
14
  from grim.view import ViewContext
14
15
 
16
+ from ..gameplay import _creature_find_in_radius, perk_count_get
15
17
  from ..game_world import GameWorld
16
18
  from ..persistence.highscores import HighScoreRecord
19
+ from ..perks import PerkId
17
20
  from ..ui.game_over import GameOverUi
18
- from ..ui.hud import HudAssets, load_hud_assets
21
+ from ..ui.hud import HudAssets, draw_target_health_bar, load_hud_assets
19
22
 
20
23
  if TYPE_CHECKING:
21
24
  from ..persistence.save_status import GameStatus
@@ -45,6 +48,7 @@ class BaseGameplayMode:
45
48
  hardcore: bool = False,
46
49
  texture_cache: PaqTextureCache | None = None,
47
50
  config: CrimsonConfig | None = None,
51
+ console: ConsoleState | None = None,
48
52
  audio: AudioState | None = None,
49
53
  audio_rng: random.Random | None = None,
50
54
  ) -> None:
@@ -54,7 +58,9 @@ class BaseGameplayMode:
54
58
  self._small: SmallFontData | None = None
55
59
  self._hud_assets: HudAssets | None = None
56
60
 
61
+ self._default_game_mode_id = int(default_game_mode_id)
57
62
  self._config = config
63
+ self._console = console
58
64
  self._base_dir = config.path.parent if config is not None else Path.cwd()
59
65
 
60
66
  self.close_requested = False
@@ -90,6 +96,74 @@ class BaseGameplayMode:
90
96
  self._last_dt_ms = 0.0
91
97
  self._screen_fade: _ScreenFade | None = None
92
98
 
99
+ def _cvar_float(self, name: str, default: float = 0.0) -> float:
100
+ console = self._console
101
+ if console is None:
102
+ return float(default)
103
+ cvar = console.cvars.get(name)
104
+ if cvar is None:
105
+ return float(default)
106
+ return float(cvar.value_f)
107
+
108
+ def _hud_small_indicators(self) -> bool:
109
+ return self._cvar_float("cv_uiSmallIndicators", 0.0) != 0.0
110
+
111
+ def _config_game_mode_id(self) -> int:
112
+ config = self._config
113
+ if config is None:
114
+ return int(self._default_game_mode_id)
115
+ try:
116
+ value = config.data.get("game_mode", self._default_game_mode_id)
117
+ return int(value or self._default_game_mode_id)
118
+ except Exception:
119
+ return int(self._default_game_mode_id)
120
+
121
+ def _draw_target_health_bar(self, *, alpha: float = 1.0) -> None:
122
+ creatures = getattr(self._creatures, "entries", [])
123
+ if not creatures:
124
+ return
125
+
126
+ if perk_count_get(self._player, PerkId.DOCTOR) <= 0:
127
+ return
128
+
129
+ target_idx = _creature_find_in_radius(
130
+ creatures,
131
+ pos_x=float(getattr(self._player, "aim_x", 0.0)),
132
+ pos_y=float(getattr(self._player, "aim_y", 0.0)),
133
+ radius=12.0,
134
+ start_index=0,
135
+ )
136
+ if target_idx == -1:
137
+ return
138
+
139
+ creature = creatures[target_idx]
140
+ if not bool(getattr(creature, "active", False)):
141
+ return
142
+ hp = float(getattr(creature, "hp", 0.0))
143
+ max_hp = float(getattr(creature, "max_hp", 0.0))
144
+ if hp <= 0.0 or max_hp <= 0.0:
145
+ return
146
+
147
+ ratio = hp / max_hp
148
+ if ratio < 0.0:
149
+ ratio = 0.0
150
+ if ratio > 1.0:
151
+ ratio = 1.0
152
+
153
+ x0, y0 = self._world.world_to_screen(float(creature.x) - 32.0, float(creature.y) + 32.0)
154
+ x1, _y1 = self._world.world_to_screen(float(creature.x) + 32.0, float(creature.y) + 32.0)
155
+ width = float(x1) - float(x0)
156
+ if width <= 1e-3:
157
+ return
158
+ draw_target_health_bar(
159
+ x=float(x0),
160
+ y=float(y0),
161
+ width=width,
162
+ ratio=ratio,
163
+ alpha=float(alpha),
164
+ scale=width / 64.0,
165
+ )
166
+
93
167
  def _bind_world(self) -> None:
94
168
  self._state = self._world.state
95
169
  self._creatures = self._world.creatures