crimsonland 0.1.0.dev5__py3-none-any.whl → 0.1.0.dev7__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/demo.py CHANGED
@@ -690,8 +690,8 @@ class DemoView:
690
690
  rl.draw_circle(int(sx), int(sy), radius, rl.Color(200, 120, 255, 255))
691
691
  continue
692
692
  if proj.type_id == 3:
693
- t = _clamp(proj.lifetime, 0.0, 1.0)
694
- radius = proj.speed * t * 80.0
693
+ t = _clamp(proj.vel_x, 0.0, 1.0)
694
+ radius = proj.vel_y * t * 80.0
695
695
  alpha = int((1.0 - t) * 180.0)
696
696
  color = rl.Color(200, 120, 255, alpha)
697
697
  rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
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/gameplay.py CHANGED
@@ -358,11 +358,49 @@ class BonusPool:
358
358
  return None
359
359
 
360
360
  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
361
+ # Native special-case: while any player has Pistol, 3/4 chance to force a Weapon drop.
362
+ if players and any(int(player.weapon_id) == int(WeaponId.PISTOL) for player in players):
363
+ if (int(rng.rand()) & 3) < 3:
364
+ entry = self.spawn_at_pos(
365
+ pos_x,
366
+ pos_y,
367
+ state=state,
368
+ players=players,
369
+ world_width=world_width,
370
+ world_height=world_height,
371
+ )
372
+ if entry is None:
373
+ return None
374
+
375
+ entry.bonus_id = int(BonusId.WEAPON)
376
+ weapon_id = int(weapon_pick_random_available(state))
377
+ entry.amount = int(weapon_id)
378
+ if weapon_id == int(WeaponId.PISTOL):
379
+ weapon_id = int(weapon_pick_random_available(state))
380
+ entry.amount = int(weapon_id)
381
+
382
+ matches = sum(1 for bonus in self._entries if bonus.bonus_id == entry.bonus_id)
383
+ if matches > 1:
384
+ self._clear_entry(entry)
385
+ return None
386
+
387
+ if entry.amount == int(WeaponId.PISTOL) or (players and perk_active(players[0], PerkId.MY_FAVOURITE_WEAPON)):
388
+ self._clear_entry(entry)
389
+ return None
390
+
391
+ return entry
392
+
393
+ base_roll = int(rng.rand())
394
+ if base_roll % 9 != 1:
395
+ allow_without_magnet = False
396
+ if players and int(players[0].weapon_id) == int(WeaponId.PISTOL):
397
+ allow_without_magnet = int(rng.rand()) % 5 == 1
398
+
399
+ if not allow_without_magnet:
400
+ if not (players and perk_active(players[0], PerkId.BONUS_MAGNET)):
401
+ return None
402
+ if int(rng.rand()) % 10 != 2:
403
+ return None
366
404
 
367
405
  entry = self.spawn_at_pos(
368
406
  pos_x,
@@ -377,11 +415,9 @@ class BonusPool:
377
415
 
378
416
  if entry.bonus_id == int(BonusId.WEAPON):
379
417
  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
418
+ if players and _distance_sq(pos_x, pos_y, players[0].pos_x, players[0].pos_y) < near_sq:
419
+ entry.bonus_id = int(BonusId.POINTS)
420
+ entry.amount = 100
385
421
 
386
422
  if entry.bonus_id != int(BonusId.POINTS):
387
423
  matches = sum(1 for bonus in self._entries if bonus.bonus_id == entry.bonus_id)
@@ -390,10 +426,9 @@ class BonusPool:
390
426
  return None
391
427
 
392
428
  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
429
+ if players and entry.amount == players[0].weapon_id:
430
+ self._clear_entry(entry)
431
+ return None
397
432
 
398
433
  return entry
399
434
 
@@ -1626,7 +1661,7 @@ def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float,
1626
1661
 
1627
1662
  shot_cooldown = float(weapon.shot_cooldown) if weapon.shot_cooldown is not None else 0.0
1628
1663
  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:
1664
+ 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
1665
  spread_heat_base = float(fire_bullets_weapon.spread_heat_inc)
1631
1666
 
1632
1667
  if is_fire_bullets and pellet_count == 1 and fire_bullets_weapon is not None:
@@ -1681,9 +1716,7 @@ def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float,
1681
1716
  shot_count = pellets
1682
1717
  meta = _projectile_meta_for_type_id(ProjectileTypeId.FIRE_BULLETS)
1683
1718
  for _ in range(pellets):
1684
- angle = shot_angle
1685
- if pellets > 1:
1686
- angle += float(int(state.rng.rand()) % 200 - 100) * 0.0015
1719
+ angle = shot_angle + float(int(state.rng.rand()) % 200 - 100) * 0.0015
1687
1720
  state.projectiles.spawn(
1688
1721
  pos_x=muzzle_x,
1689
1722
  pos_y=muzzle_y,
crimson/projectiles.py CHANGED
@@ -112,7 +112,7 @@ class SecondaryProjectile:
112
112
  vel_y: float = 0.0
113
113
  type_id: int = 0
114
114
  owner_id: int = -100
115
- lifetime: float = 0.0
115
+ trail_timer: float = 0.0
116
116
  target_id: int = -1
117
117
 
118
118
 
@@ -383,6 +383,86 @@ class ProjectilePool:
383
383
  detail_preset=detail,
384
384
  )
385
385
 
386
+ def _spawn_plasma_cannon_hit_effects(pos_x: float, pos_y: float) -> None:
387
+ """Port of `projectile_update` Plasma Cannon hit extras.
388
+
389
+ Native does:
390
+ - `sfx_play_panned(sfx_explosion_medium)`
391
+ - `sfx_play_panned(sfx_shockwave)`
392
+ - `FUN_0042f330(pos, 1.5, 1.0)`
393
+ - `FUN_0042f330(pos, 1.0, 1.0)`
394
+ """
395
+
396
+ if effects is None or not hasattr(effects, "spawn"):
397
+ return
398
+
399
+ if isinstance(sfx_queue, list):
400
+ sfx_queue.append("sfx_explosion_medium")
401
+ sfx_queue.append("sfx_shockwave")
402
+
403
+ detail = int(detail_preset)
404
+
405
+ def _spawn_ring(*, scale: float) -> None:
406
+ effects.spawn(
407
+ effect_id=1,
408
+ pos_x=float(pos_x),
409
+ pos_y=float(pos_y),
410
+ vel_x=0.0,
411
+ vel_y=0.0,
412
+ rotation=0.0,
413
+ scale=1.0,
414
+ half_width=4.0,
415
+ half_height=4.0,
416
+ age=0.1,
417
+ lifetime=1.0,
418
+ flags=0x19,
419
+ color_r=0.9,
420
+ color_g=0.6,
421
+ color_b=0.3,
422
+ color_a=1.0,
423
+ rotation_step=0.0,
424
+ scale_step=float(scale) * 45.0,
425
+ detail_preset=detail,
426
+ )
427
+
428
+ _spawn_ring(scale=1.5)
429
+ _spawn_ring(scale=1.0)
430
+
431
+ def _spawn_splitter_hit_effects(pos_x: float, pos_y: float) -> None:
432
+ """Port of `FUN_0042f3f0(pos, 26.0, 3)` from the Splitter Gun hit branch."""
433
+
434
+ if effects is None or not hasattr(effects, "spawn"):
435
+ return
436
+
437
+ detail = int(detail_preset)
438
+ for _ in range(3):
439
+ angle = float(int(rng()) & 0x1FF) * (math.tau / 512.0)
440
+ radius = float(int(rng()) % 26)
441
+ jitter_age = -float(int(rng()) & 0xFF) * 0.0012
442
+ lifetime = 0.1 - jitter_age
443
+
444
+ effects.spawn(
445
+ effect_id=0,
446
+ pos_x=float(pos_x) + math.cos(angle) * radius,
447
+ pos_y=float(pos_y) + math.sin(angle) * radius,
448
+ vel_x=0.0,
449
+ vel_y=0.0,
450
+ rotation=0.0,
451
+ scale=1.0,
452
+ half_width=4.0,
453
+ half_height=4.0,
454
+ age=jitter_age,
455
+ lifetime=lifetime,
456
+ flags=0x19,
457
+ color_r=1.0,
458
+ color_g=0.9,
459
+ color_b=0.1,
460
+ color_a=1.0,
461
+ rotation_step=0.0,
462
+ scale_step=55.0,
463
+ detail_preset=detail,
464
+ )
465
+
386
466
  def _apply_damage_to_creature(
387
467
  creature_index: int,
388
468
  damage: float,
@@ -567,54 +647,6 @@ class ProjectilePool:
567
647
  acc_x = 0.0
568
648
  acc_y = 0.0
569
649
 
570
- if proj.hits_players:
571
- hit_player_idx = None
572
- if players is not None:
573
- for idx, player in enumerate(players):
574
- if float(player.health) <= 0.0:
575
- continue
576
- player_radius = _hit_radius_for(player)
577
- hit_r = proj.hit_radius + player_radius
578
- if _distance_sq(proj.pos_x, proj.pos_y, player.pos_x, player.pos_y) <= hit_r * hit_r:
579
- hit_player_idx = idx
580
- break
581
-
582
- if hit_player_idx is None:
583
- step += 3
584
- continue
585
-
586
- type_id = proj.type_id
587
- hit_x = float(proj.pos_x)
588
- hit_y = float(proj.pos_y)
589
- player = players[int(hit_player_idx)] if players is not None else None
590
- target_x = float(getattr(player, "pos_x", hit_x) if player is not None else hit_x)
591
- target_y = float(getattr(player, "pos_y", hit_y) if player is not None else hit_y)
592
- hits.append((type_id, proj.origin_x, proj.origin_y, hit_x, hit_y, target_x, target_y))
593
-
594
- if proj.life_timer != 0.25 and type_id not in (
595
- ProjectileTypeId.FIRE_BULLETS,
596
- ProjectileTypeId.GAUSS_GUN,
597
- ProjectileTypeId.BLADE_GUN,
598
- ):
599
- proj.life_timer = 0.25
600
- jitter = rng() & 3
601
- proj.pos_x += dir_x * float(jitter)
602
- proj.pos_y += dir_y * float(jitter)
603
-
604
- dist = math.hypot(proj.origin_x - proj.pos_x, proj.origin_y - proj.pos_y)
605
- if dist < 50.0:
606
- dist = 50.0
607
-
608
- damage_scale = _damage_scale(type_id)
609
- damage_amount = ((100.0 / dist) * damage_scale * 30.0 + 10.0) * 0.95
610
- if damage_amount > 0.0:
611
- if apply_player_damage is not None:
612
- apply_player_damage(int(hit_player_idx), float(damage_amount))
613
- elif players is not None:
614
- players[int(hit_player_idx)].health -= float(damage_amount)
615
-
616
- break
617
-
618
650
  hit_idx = None
619
651
  for idx, creature in enumerate(creatures):
620
652
  if creature.hp <= 0.0:
@@ -628,6 +660,61 @@ class ProjectilePool:
628
660
  break
629
661
 
630
662
  if hit_idx is None:
663
+ if proj.hits_players:
664
+ hit_player_idx = None
665
+ owner_id = int(proj.owner_id)
666
+ owner_player_index = -1 - owner_id if owner_id < 0 and owner_id != -100 else None
667
+ if players is not None:
668
+ for idx, player in enumerate(players):
669
+ if owner_player_index is not None and idx == owner_player_index:
670
+ continue
671
+ if float(player.health) <= 0.0:
672
+ continue
673
+ player_radius = _hit_radius_for(player)
674
+ hit_r = proj.hit_radius + player_radius
675
+ if (
676
+ _distance_sq(proj.pos_x, proj.pos_y, player.pos_x, player.pos_y)
677
+ <= hit_r * hit_r
678
+ ):
679
+ hit_player_idx = idx
680
+ break
681
+
682
+ if hit_player_idx is None:
683
+ step += 3
684
+ continue
685
+
686
+ type_id = proj.type_id
687
+ hit_x = float(proj.pos_x)
688
+ hit_y = float(proj.pos_y)
689
+ player = players[int(hit_player_idx)] if players is not None else None
690
+ target_x = float(getattr(player, "pos_x", hit_x) if player is not None else hit_x)
691
+ target_y = float(getattr(player, "pos_y", hit_y) if player is not None else hit_y)
692
+ hits.append((type_id, proj.origin_x, proj.origin_y, hit_x, hit_y, target_x, target_y))
693
+
694
+ if proj.life_timer != 0.25 and type_id not in (
695
+ ProjectileTypeId.FIRE_BULLETS,
696
+ ProjectileTypeId.GAUSS_GUN,
697
+ ProjectileTypeId.BLADE_GUN,
698
+ ):
699
+ proj.life_timer = 0.25
700
+ jitter = rng() & 3
701
+ proj.pos_x += dir_x * float(jitter)
702
+ proj.pos_y += dir_y * float(jitter)
703
+
704
+ dist = math.hypot(proj.origin_x - proj.pos_x, proj.origin_y - proj.pos_y)
705
+ if dist < 50.0:
706
+ dist = 50.0
707
+
708
+ damage_scale = _damage_scale(type_id)
709
+ damage_amount = ((100.0 / dist) * damage_scale * 30.0 + 10.0) * 0.95
710
+ if damage_amount > 0.0:
711
+ if apply_player_damage is not None:
712
+ apply_player_damage(int(hit_player_idx), float(damage_amount))
713
+ elif players is not None:
714
+ players[int(hit_player_idx)].health -= float(damage_amount)
715
+
716
+ break
717
+
631
718
  step += 3
632
719
  continue
633
720
 
@@ -639,6 +726,7 @@ class ProjectilePool:
639
726
  creature.flags |= CreatureFlags.SELF_DAMAGE_TICK
640
727
 
641
728
  if type_id == ProjectileTypeId.SPLITTER_GUN:
729
+ _spawn_splitter_hit_effects(proj.pos_x, proj.pos_y)
642
730
  self.spawn(
643
731
  pos_x=proj.pos_x,
644
732
  pos_y=proj.pos_y,
@@ -710,6 +798,7 @@ class ProjectilePool:
710
798
  owner_id=-100,
711
799
  base_damage=plasma_meta,
712
800
  )
801
+ _spawn_plasma_cannon_hit_effects(proj.pos_x, proj.pos_y)
713
802
  elif type_id == ProjectileTypeId.SHRINKIFIER:
714
803
  if hasattr(creature, "size"):
715
804
  new_size = float(getattr(creature, "size", 50.0) or 50.0) * 0.65
@@ -914,12 +1003,13 @@ class SecondaryProjectilePool:
914
1003
  entry.pos_y = float(pos_y)
915
1004
  entry.owner_id = int(owner_id)
916
1005
  entry.target_id = -1
1006
+ entry.trail_timer = 0.0
917
1007
 
918
1008
  if entry.type_id == 3:
1009
+ # Detonation state: `vel_x` becomes the expansion timer and `vel_y` the scale.
919
1010
  entry.vel_x = 0.0
920
- entry.vel_y = 0.0
1011
+ entry.vel_y = float(time_to_live)
921
1012
  entry.speed = float(time_to_live)
922
- entry.lifetime = 0.0
923
1013
  return index
924
1014
 
925
1015
  # Effects.md: vel = cos/sin(angle - PI/2) * 90 (190 for type 2).
@@ -931,7 +1021,6 @@ class SecondaryProjectilePool:
931
1021
  entry.vel_x = vx
932
1022
  entry.vel_y = vy
933
1023
  entry.speed = float(time_to_live)
934
- entry.lifetime = 0.0
935
1024
  return index
936
1025
 
937
1026
  def iter_active(self) -> list[SecondaryProjectile]:
@@ -966,6 +1055,7 @@ class SecondaryProjectilePool:
966
1055
  rand = _rng_zero
967
1056
  freeze_active = False
968
1057
  effects = None
1058
+ sprite_effects = None
969
1059
  sfx_queue = None
970
1060
  if runtime_state is not None:
971
1061
  rng = getattr(runtime_state, "rng", None)
@@ -977,6 +1067,7 @@ class SecondaryProjectilePool:
977
1067
  freeze_active = True
978
1068
 
979
1069
  effects = getattr(runtime_state, "effects", None)
1070
+ sprite_effects = getattr(runtime_state, "sprite_effects", None)
980
1071
  sfx_queue = getattr(runtime_state, "sfx_queue", None)
981
1072
 
982
1073
  for entry in self._entries:
@@ -984,9 +1075,10 @@ class SecondaryProjectilePool:
984
1075
  continue
985
1076
 
986
1077
  if entry.type_id == 3:
987
- entry.lifetime += dt * 3.0
988
- t = entry.lifetime
989
- scale = entry.speed
1078
+ # Detonation: `vel_x` becomes the expansion timer (0..1) and `vel_y` the scale.
1079
+ entry.vel_x += dt * 3.0
1080
+ t = float(entry.vel_x)
1081
+ scale = float(entry.vel_y)
990
1082
  if t > 1.0:
991
1083
  if fx_queue is not None:
992
1084
  fx_queue.add(
@@ -1066,6 +1158,23 @@ class SecondaryProjectilePool:
1066
1158
 
1067
1159
  entry.speed -= dt * 0.5
1068
1160
 
1161
+ # Rocket smoke trail (`trail_timer` in crimsonland.exe).
1162
+ entry.trail_timer -= (abs(float(entry.vel_x)) + abs(float(entry.vel_y))) * dt * 0.01
1163
+ if entry.trail_timer < 0.0:
1164
+ dir_x = math.cos(float(entry.angle) - math.pi / 2.0)
1165
+ dir_y = math.sin(float(entry.angle) - math.pi / 2.0)
1166
+ spawn_x = float(entry.pos_x) - dir_x * 9.0
1167
+ spawn_y = float(entry.pos_y) - dir_y * 9.0
1168
+ vel_x = math.cos(float(entry.angle) + math.pi / 2.0) * 90.0
1169
+ vel_y = math.sin(float(entry.angle) + math.pi / 2.0) * 90.0
1170
+ if sprite_effects is not None and hasattr(sprite_effects, "spawn"):
1171
+ sprite_id = sprite_effects.spawn(pos_x=spawn_x, pos_y=spawn_y, vel_x=vel_x, vel_y=vel_y, scale=14.0)
1172
+ try:
1173
+ sprite_effects.entries[int(sprite_id)].color_a = 0.25
1174
+ except Exception:
1175
+ pass
1176
+ entry.trail_timer = 0.06
1177
+
1069
1178
  # projectile_update uses creature_find_in_radius(..., 8.0, ...)
1070
1179
  hit_idx: int | None = None
1071
1180
  for idx, creature in enumerate(creatures):
@@ -1118,16 +1227,70 @@ class SecondaryProjectilePool:
1118
1227
  rand=rand,
1119
1228
  )
1120
1229
 
1230
+ if entry.type_id == 1 and effects is not None and hasattr(effects, "spawn_explosion_burst") and int(detail_preset) > 2:
1231
+ effects.spawn_explosion_burst(
1232
+ pos_x=float(entry.pos_x),
1233
+ pos_y=float(entry.pos_y),
1234
+ scale=0.4,
1235
+ rand=rand,
1236
+ detail_preset=int(detail_preset),
1237
+ )
1238
+
1121
1239
  entry.type_id = 3
1122
1240
  entry.vel_x = 0.0
1123
- entry.vel_y = 0.0
1124
- entry.speed = det_scale
1125
- entry.lifetime = 0.0
1241
+ entry.vel_y = float(det_scale)
1242
+ entry.trail_timer = 0.0
1243
+
1244
+ # Extra debris and scorch decals on detonation.
1245
+ if not freeze_active:
1246
+ extra_decals = 0
1247
+ extra_radius = 0.0
1248
+ if entry.type_id == 3:
1249
+ # NOTE: entry.type_id is already 3 here; use det_scale based on prior type.
1250
+ if det_scale == 1.0:
1251
+ extra_decals = 0x14
1252
+ extra_radius = 90.0
1253
+ elif det_scale == 0.35:
1254
+ extra_decals = 10
1255
+ extra_radius = 63.0
1256
+ elif det_scale == 0.25:
1257
+ extra_decals = 3
1258
+ extra_radius = 44.0
1259
+ if fx_queue is not None and extra_decals > 0:
1260
+ cx = float(creatures[hit_idx].x)
1261
+ cy = float(creatures[hit_idx].y)
1262
+ for _ in range(int(extra_decals)):
1263
+ angle = float(int(rand()) % 0x274) * 0.01
1264
+ radius = float(int(rand()) % max(1, int(extra_radius)))
1265
+ fx_queue.add_random(
1266
+ pos_x=cx + math.cos(angle) * radius,
1267
+ pos_y=cy + math.sin(angle) * radius,
1268
+ rand=rand,
1269
+ )
1270
+
1271
+ if sprite_effects is not None and hasattr(sprite_effects, "spawn"):
1272
+ step = math.tau / 10.0
1273
+ for idx in range(10):
1274
+ mag = float(int(rand()) % 800) * 0.1
1275
+ ang = float(idx) * step
1276
+ vel_x = math.cos(ang) * mag
1277
+ vel_y = math.sin(ang) * mag
1278
+ sprite_id = sprite_effects.spawn(
1279
+ pos_x=float(entry.pos_x),
1280
+ pos_y=float(entry.pos_y),
1281
+ vel_x=vel_x,
1282
+ vel_y=vel_y,
1283
+ scale=14.0,
1284
+ )
1285
+ try:
1286
+ sprite_effects.entries[int(sprite_id)].color_a = 0.37
1287
+ except Exception:
1288
+ pass
1289
+
1126
1290
  continue
1127
1291
 
1128
1292
  if entry.speed <= 0.0:
1129
1293
  entry.type_id = 3
1130
1294
  entry.vel_x = 0.0
1131
- entry.vel_y = 0.0
1132
- entry.speed = 0.5
1133
- entry.lifetime = 0.0
1295
+ entry.vel_y = 0.5
1296
+ entry.trail_timer = 0.0
@@ -452,13 +452,13 @@ class WorldRenderer:
452
452
  ):
453
453
  atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x10)
454
454
  if atlas is not None:
455
- grid = SIZE_CODE_GRID.get(int(atlas.size_code))
456
- if grid:
455
+ aura_grid = SIZE_CODE_GRID.get(int(atlas.size_code))
456
+ if aura_grid:
457
457
  frame = int(atlas.frame)
458
- col = frame % grid
459
- row = frame // grid
460
- cell_w = float(self.particles_texture.width) / float(grid)
461
- cell_h = float(self.particles_texture.height) / float(grid)
458
+ col = frame % aura_grid
459
+ row = frame // aura_grid
460
+ cell_w = float(self.particles_texture.width) / float(aura_grid)
461
+ cell_h = float(self.particles_texture.height) / float(aura_grid)
462
462
  src = rl.Rectangle(
463
463
  cell_w * float(col),
464
464
  cell_h * float(row),
@@ -922,6 +922,7 @@ class WorldRenderer:
922
922
  origin = rl.Vector2(size * 0.5, size * 0.5)
923
923
  rl.draw_texture_pro(particles_texture, src, dst, origin, float(angle * _RAD_TO_DEG), tint)
924
924
  else:
925
+ # Native draws a small blue "core" at the head during the fade stage (life_timer < 0.4).
925
926
  core_tint = self._color_from_rgba((0.5, 0.6, 1.0, base_alpha))
926
927
  self._draw_atlas_sprite(
927
928
  texture,
@@ -935,13 +936,8 @@ class WorldRenderer:
935
936
  )
936
937
 
937
938
  if is_ion:
938
- if type_id == int(ProjectileTypeId.ION_RIFLE):
939
- radius = 88.0
940
- elif type_id == int(ProjectileTypeId.ION_MINIGUN):
941
- radius = 60.0
942
- else:
943
- radius = 128.0
944
- radius *= perk_scale
939
+ # Native: chain reach is derived from the streak scale (`fVar29 * perk_scale * 40.0`).
940
+ radius = effect_scale * perk_scale * 40.0
945
941
 
946
942
  # Pick a stable set of targets so the arc visuals don't flicker.
947
943
  candidates: list[tuple[float, object]] = []
@@ -1346,6 +1342,99 @@ class WorldRenderer:
1346
1342
  rl.end_blend_mode()
1347
1343
  return True
1348
1344
 
1345
+ def _draw_sharpshooter_laser_sight(
1346
+ self,
1347
+ *,
1348
+ cam_x: float,
1349
+ cam_y: float,
1350
+ scale_x: float,
1351
+ scale_y: float,
1352
+ scale: float,
1353
+ alpha: float,
1354
+ ) -> None:
1355
+ """Laser sight overlay for the Sharpshooter perk (`projectile_render` @ 0x00422c70)."""
1356
+
1357
+ alpha = clamp(float(alpha), 0.0, 1.0)
1358
+ if alpha <= 1e-3:
1359
+ return
1360
+ if self.bullet_trail_texture is None:
1361
+ return
1362
+
1363
+ players = self.players
1364
+ if not players:
1365
+ return
1366
+
1367
+ tail_alpha = int(clamp(alpha * 0.5, 0.0, 1.0) * 255.0 + 0.5)
1368
+ head_alpha = int(clamp(alpha * 0.2, 0.0, 1.0) * 255.0 + 0.5)
1369
+ tail = rl.Color(255, 0, 0, tail_alpha)
1370
+ head = rl.Color(255, 0, 0, head_alpha)
1371
+
1372
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1373
+ rl.rl_set_texture(self.bullet_trail_texture.id)
1374
+ rl.rl_begin(rl.RL_QUADS)
1375
+
1376
+ for player in players:
1377
+ if float(getattr(player, "health", 0.0)) <= 0.0:
1378
+ continue
1379
+ if not perk_active(player, PerkId.SHARPSHOOTER):
1380
+ continue
1381
+
1382
+ aim_heading = float(getattr(player, "aim_heading", 0.0))
1383
+ dir_x = math.cos(aim_heading - math.pi / 2.0)
1384
+ dir_y = math.sin(aim_heading - math.pi / 2.0)
1385
+
1386
+ start_x = float(getattr(player, "pos_x", 0.0)) + dir_x * 15.0
1387
+ start_y = float(getattr(player, "pos_y", 0.0)) + dir_y * 15.0
1388
+ end_x = float(getattr(player, "pos_x", 0.0)) + dir_x * 512.0
1389
+ end_y = float(getattr(player, "pos_y", 0.0)) + dir_y * 512.0
1390
+
1391
+ sx0 = (start_x + cam_x) * scale_x
1392
+ sy0 = (start_y + cam_y) * scale_y
1393
+ sx1 = (end_x + cam_x) * scale_x
1394
+ sy1 = (end_y + cam_y) * scale_y
1395
+
1396
+ dx = sx1 - sx0
1397
+ dy = sy1 - sy0
1398
+ dist = math.hypot(dx, dy)
1399
+ if dist <= 1e-3:
1400
+ continue
1401
+
1402
+ thickness = max(1.0, 2.0 * scale)
1403
+ half = thickness * 0.5
1404
+ inv = 1.0 / dist
1405
+ nx = dx * inv
1406
+ ny = dy * inv
1407
+ px = -ny
1408
+ py = nx
1409
+ ox = px * half
1410
+ oy = py * half
1411
+
1412
+ x0 = sx0 - ox
1413
+ y0 = sy0 - oy
1414
+ x1 = sx0 + ox
1415
+ y1 = sy0 + oy
1416
+ x2 = sx1 + ox
1417
+ y2 = sy1 + oy
1418
+ x3 = sx1 - ox
1419
+ y3 = sy1 - oy
1420
+
1421
+ rl.rl_color4ub(tail.r, tail.g, tail.b, tail.a)
1422
+ rl.rl_tex_coord2f(0.0, 0.0)
1423
+ rl.rl_vertex2f(x0, y0)
1424
+ rl.rl_color4ub(tail.r, tail.g, tail.b, tail.a)
1425
+ rl.rl_tex_coord2f(1.0, 0.0)
1426
+ rl.rl_vertex2f(x1, y1)
1427
+ rl.rl_color4ub(head.r, head.g, head.b, head.a)
1428
+ rl.rl_tex_coord2f(1.0, 0.5)
1429
+ rl.rl_vertex2f(x2, y2)
1430
+ rl.rl_color4ub(head.r, head.g, head.b, head.a)
1431
+ rl.rl_tex_coord2f(0.0, 0.5)
1432
+ rl.rl_vertex2f(x3, y3)
1433
+
1434
+ rl.rl_end()
1435
+ rl.rl_set_texture(0)
1436
+ rl.end_blend_mode()
1437
+
1349
1438
  def _draw_secondary_projectile(self, proj: object, *, scale: float, alpha: float = 1.0) -> None:
1350
1439
  alpha = clamp(float(alpha), 0.0, 1.0)
1351
1440
  if alpha <= 1e-3:
@@ -1436,11 +1525,53 @@ class WorldRenderer:
1436
1525
  rl.draw_circle(int(sx), int(sy), max(1.0, 12.0 * scale), rl.Color(200, 120, 255, int(255 * alpha + 0.5)))
1437
1526
  return
1438
1527
  if proj_type == 3:
1439
- t = clamp(float(getattr(proj, "lifetime", 0.0)), 0.0, 1.0)
1440
- radius = float(getattr(proj, "speed", 1.0)) * t * 80.0
1441
- alpha_byte = int(clamp((1.0 - t) * 180.0 * alpha, 0.0, 255.0) + 0.5)
1442
- color = rl.Color(200, 120, 255, alpha_byte)
1443
- rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
1528
+ # Secondary projectile detonation visuals (secondary_projectile_update + render).
1529
+ t = clamp(float(getattr(proj, "vel_x", 0.0)), 0.0, 1.0)
1530
+ det_scale = float(getattr(proj, "vel_y", 1.0))
1531
+ fade = (1.0 - t) * alpha
1532
+ if fade <= 1e-3 or det_scale <= 1e-6:
1533
+ return
1534
+ if self.particles_texture is None:
1535
+ radius = det_scale * t * 80.0
1536
+ alpha_byte = int(clamp((1.0 - t) * 180.0 * alpha, 0.0, 255.0) + 0.5)
1537
+ color = rl.Color(255, 180, 100, alpha_byte)
1538
+ rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
1539
+ return
1540
+
1541
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x0D)
1542
+ if atlas is None:
1543
+ return
1544
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1545
+ if not grid:
1546
+ return
1547
+ frame = int(atlas.frame)
1548
+ col = frame % grid
1549
+ row = frame // grid
1550
+ cell_w = float(self.particles_texture.width) / float(grid)
1551
+ cell_h = float(self.particles_texture.height) / float(grid)
1552
+ src = rl.Rectangle(
1553
+ cell_w * float(col),
1554
+ cell_h * float(row),
1555
+ max(0.0, cell_w - 2.0),
1556
+ max(0.0, cell_h - 2.0),
1557
+ )
1558
+
1559
+ def _draw_detonation_quad(*, size: float, alpha_mul: float) -> None:
1560
+ a = fade * alpha_mul
1561
+ if a <= 1e-3:
1562
+ return
1563
+ dst_size = size * scale
1564
+ if dst_size <= 1e-3:
1565
+ return
1566
+ tint = self._color_from_rgba((1.0, 0.6, 0.1, a))
1567
+ dst = rl.Rectangle(float(sx), float(sy), float(dst_size), float(dst_size))
1568
+ origin = rl.Vector2(float(dst_size) * 0.5, float(dst_size) * 0.5)
1569
+ rl.draw_texture_pro(self.particles_texture, src, dst, origin, 0.0, tint)
1570
+
1571
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1572
+ _draw_detonation_quad(size=det_scale * t * 64.0, alpha_mul=1.0)
1573
+ _draw_detonation_quad(size=det_scale * t * 200.0, alpha_mul=0.3)
1574
+ rl.end_blend_mode()
1444
1575
  return
1445
1576
  rl.draw_circle(int(sx), int(sy), max(1.0, 4.0 * scale), rl.Color(200, 200, 220, int(200 * alpha + 0.5)))
1446
1577
 
@@ -1488,7 +1619,7 @@ class WorldRenderer:
1488
1619
  rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1489
1620
 
1490
1621
  if fx_detail_1 and src_large is not None:
1491
- alpha_byte = int(clamp(alpha * 0.04, 0.0, 1.0) * 255.0 + 0.5)
1622
+ alpha_byte = int(clamp(alpha * 0.065, 0.0, 1.0) * 255.0 + 0.5)
1492
1623
  tint = rl.Color(255, 255, 255, alpha_byte)
1493
1624
  for idx, entry in enumerate(particles):
1494
1625
  if not entry.active or (idx % 2) or int(entry.style_id) == 8:
@@ -1893,6 +2024,15 @@ class WorldRenderer:
1893
2024
  if player.health > 0.0:
1894
2025
  draw_player(player)
1895
2026
 
2027
+ self._draw_sharpshooter_laser_sight(
2028
+ cam_x=cam_x,
2029
+ cam_y=cam_y,
2030
+ scale_x=scale_x,
2031
+ scale_y=scale_y,
2032
+ scale=scale,
2033
+ alpha=entity_alpha,
2034
+ )
2035
+
1896
2036
  for proj_index, proj in enumerate(self.state.projectiles.entries):
1897
2037
  if not proj.active:
1898
2038
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: crimsonland
3
- Version: 0.1.0.dev5
3
+ Version: 0.1.0.dev7
4
4
  Requires-Dist: construct>=2.10.70
5
5
  Requires-Dist: pillow>=12.1.0
6
6
  Requires-Dist: platformdirs>=4.5.1
@@ -1,7 +1,7 @@
1
1
  crimson/__init__.py,sha256=dij6OQ6Wctqur9P00ojTTZw6IaUNeZagPX4-2Qqr-Kw,367
2
2
  crimson/assets_fetch.py,sha256=z4vFH9h-RIwc2o6uKGzEtUaOdhUDSX6Art-WU6tNjd0,1864
3
3
  crimson/atlas.py,sha256=hEcCHhPvguXAI6eH_G9Q8rpiX7M5akZ8fgJjMogmYrA,2401
4
- crimson/audio_router.py,sha256=XauJvjT_qAooPKBo4d8NVe9HRWHzZDD8PCzmsWrASic,4729
4
+ crimson/audio_router.py,sha256=4lccGu5044WQ5sMz9yfZd4loSgEMDqXJWGvMmHyMGt0,5449
5
5
  crimson/bonuses.py,sha256=owwYIRHRu1Kymtt4eEvpd62JwWAg8LOe20vDuPFB5SU,5094
6
6
  crimson/camera.py,sha256=VxTNXTh0qHh5VR1OpkiXr-QcgEWPrWw0A3PFyQFqDkY,2278
7
7
  crimson/cli.py,sha256=Pu4RM_bjGtUgIE_5zZs0uWFY9O8YkhJDseRcVMMPJ8k,14595
@@ -12,9 +12,9 @@ crimson/creatures/damage.py,sha256=pjKIX32nGDVPnFaWCce0LNZ-UZXZqJpNvwHwq-DCWbE,3
12
12
  crimson/creatures/runtime.py,sha256=1FagJNwGWJuOXZEv4VaWak2IktNskSof82WCYJqui-U,40733
13
13
  crimson/creatures/spawn.py,sha256=ikgtr4sM2KdA2-eyxYdVmobcuZN6aA7xK7ceaIl7RSw,90059
14
14
  crimson/debug.py,sha256=vtfr0_HQHpiB5h57jAsl9cWyYxErSbZQ2uazcL1sJhU,127
15
- crimson/demo.py,sha256=rQG6Wdwy6t8sXZRt52vAmexpmgEyjRXuKs1sNx3wMoI,52500
15
+ crimson/demo.py,sha256=IzBcqiIilzC4PAEH6VwMpfb6y13HfieUOOfL1_kUwe0,52497
16
16
  crimson/demo_trial.py,sha256=BuoKB1DB-cPZ8jBp4x9gmxWF6tvhXMYRqJ9hMYrhxH4,4651
17
- crimson/effects.py,sha256=6-v23jb5-Xs_gu3EezjRJLCGAU8obSUR6b2opdTLTvc,33999
17
+ crimson/effects.py,sha256=tjOOZopqTI7TTOBREfIUfUhjoa3YqIoFJAPj5oJlW9Y,34056
18
18
  crimson/effects_atlas.py,sha256=Ko1O7z-1jGkH_KSeb8RrR2EtAs4V8vfo70ofhqbFak4,2104
19
19
  crimson/frontend/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
20
20
  crimson/frontend/assets.py,sha256=JHxOID6Ti6bD2LercdRJ7sSSr0nRX3WWeuRPDQwwbsY,1343
@@ -31,7 +31,7 @@ crimson/frontend/transitions.py,sha256=-sAJUDqNZ943zXlqtvJ6jCg2YH8dSi8k7qK8caAfO
31
31
  crimson/game.py,sha256=_nPXvZDgu2sqEPk00ww9qx7nD8eQIXlIvU-yKfBuUA8,97537
32
32
  crimson/game_modes.py,sha256=qW7Tt97lSBmGrt0F17Ni5h8vRyngBzyS9XwWM1TFIEI,255
33
33
  crimson/game_world.py,sha256=nfKGcm3LHChPGLHJsurDFAATrHmhRvTmgxcLzUN9m5I,25440
34
- crimson/gameplay.py,sha256=QsxHKgGTp79jAOIRoUn_L1jbN9EK2MIdCksLQ-KCqY4,87575
34
+ crimson/gameplay.py,sha256=M4ZymFV7WNF7C8zjQN179TNILDNVccfREZeymbuHD4A,89098
35
35
  crimson/input_codes.py,sha256=PmSWFZIit8unTBWk3uwifpHWMuk0qMg1ueKX3MGC7D0,5379
36
36
  crimson/modes/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
37
37
  crimson/modes/base_gameplay_mode.py,sha256=kmXJxkGnCKV5QZL_y5ML7r-p4bw7hMswKseG9XUn2jo,7555
@@ -46,7 +46,7 @@ crimson/persistence/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVO
46
46
  crimson/persistence/highscores.py,sha256=i3eU_Vlu-Bn6kZHDWNMzUoJXvzkqOnPp_UFOeZXclYU,13026
47
47
  crimson/persistence/save_status.py,sha256=s-FZONO6JVYRULewtlxoIwATTy3P7rLy-vsVBfSKPk4,7748
48
48
  crimson/player_damage.py,sha256=cECZO65NJwARoYdtsbvZBM8vQNs_8AVO4xa0omX0pGg,2215
49
- crimson/projectiles.py,sha256=44sL_2ElYAtNHHo8bzcx3t2NDq-yix56Pfui8zNGOeo,43644
49
+ crimson/projectiles.py,sha256=56yocC2XYC69bixrmAgHud4YiSIgezMf2blCQw_YekM,51304
50
50
  crimson/quests/__init__.py,sha256=pKCkH0o1XIGDN9h6yNNaqvWek2CybEZRpejYRooULj8,375
51
51
  crimson/quests/helpers.py,sha256=TeZWhIy2x8Zs9S-vYRBOEZ7pkHNMr5YJ-wi4vYQ2q2M,3625
52
52
  crimson/quests/registry.py,sha256=Cees1QBN0VYTB-XyQtvZ1b4SykB-i4tAnWF6WSV-1pg,1478
@@ -61,7 +61,7 @@ crimson/quests/timeline.py,sha256=leK898fPt3zq52v-O6dK4c-wJBcY-E6v-y57Fk3kJ3Q,40
61
61
  crimson/quests/types.py,sha256=iSrz8VSiRZh1pUDSyq37ir7B28c0HF8DjdF6OJ3FoBo,1772
62
62
  crimson/render/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
63
63
  crimson/render/terrain_fx.py,sha256=MpBV6ukGwGqDluIO86BQhHRVWUvcFOi8MV-z_TfLsiw,2705
64
- crimson/render/world_renderer.py,sha256=wSipGqm900eJaBNctXO9WDRSdM6ruOBXsG-TDXWSyCE,83776
64
+ crimson/render/world_renderer.py,sha256=9Gq2uya5duQAv4ievnqbGjJ2kXSAohVUwC36poDAp8M,88852
65
65
  crimson/sim/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
66
66
  crimson/sim/world_defs.py,sha256=HiMl--THnII3BTpt6mWAd20Xu-SRzCyHcs5pCR8aMbU,2195
67
67
  crimson/sim/world_state.py,sha256=h_PPaomj-1KPscPsW3zZ07tVFb3DYPM-FQq3pepMGng,15653
@@ -133,7 +133,7 @@ grim/sfx.py,sha256=cpn2Mmeio7BSDgbStSft-eZchO9Ot2MrK6iXJqxlLqU,7836
133
133
  grim/sfx_map.py,sha256=FM5iBzKkG30Vtu78SRavVNgXMbGK7ZFcQ8i6lgMlzVw,4697
134
134
  grim/terrain_render.py,sha256=EZ7ySYJyTZwXcrJx1mKbY3ewZtPi7Y270XnZgGJyZG8,31509
135
135
  grim/view.py,sha256=oF4pHZehBqOxPjKMU28TDg3qATh_amMIRJp-vMQnpn4,334
136
- crimsonland-0.1.0.dev5.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
137
- crimsonland-0.1.0.dev5.dist-info/entry_points.txt,sha256=jzzcExxiE9xpt4Iw2nbB1lwTv2Zj4H14WJTIPMkAjoE,77
138
- crimsonland-0.1.0.dev5.dist-info/METADATA,sha256=kBl208VLbh6OZaSSjTWXJ3pMc2q9tFXkDEJYhjg1BZc,243
139
- crimsonland-0.1.0.dev5.dist-info/RECORD,,
136
+ crimsonland-0.1.0.dev7.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
137
+ crimsonland-0.1.0.dev7.dist-info/entry_points.txt,sha256=jzzcExxiE9xpt4Iw2nbB1lwTv2Zj4H14WJTIPMkAjoE,77
138
+ crimsonland-0.1.0.dev7.dist-info/METADATA,sha256=6zsYDOJkNTgz5xZz1aIWW8BhL8aEcqZFKarztRyetBw,243
139
+ crimsonland-0.1.0.dev7.dist-info/RECORD,,