zombie-escape 1.12.3__py3-none-any.whl → 1.13.1__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.
zombie_escape/entities.py CHANGED
@@ -30,6 +30,9 @@ from .entities_constants import (
30
30
  HUMANOID_WALL_BUMP_FRAMES,
31
31
  INTERNAL_WALL_BEVEL_DEPTH,
32
32
  INTERNAL_WALL_HEALTH,
33
+ JUMP_DURATION_MS,
34
+ JUMP_SCALE_MAX,
35
+ PLAYER_JUMP_RANGE,
33
36
  PLAYER_RADIUS,
34
37
  PLAYER_SPEED,
35
38
  PLAYER_WALL_DAMAGE,
@@ -38,6 +41,7 @@ from .entities_constants import (
38
41
  STEEL_BEAM_HEALTH,
39
42
  SURVIVOR_APPROACH_RADIUS,
40
43
  SURVIVOR_APPROACH_SPEED,
44
+ SURVIVOR_JUMP_RANGE,
41
45
  SURVIVOR_RADIUS,
42
46
  ZOMBIE_AGING_DURATION_FRAMES,
43
47
  ZOMBIE_AGING_MIN_SPEED_RATIO,
@@ -45,23 +49,27 @@ from .entities_constants import (
45
49
  ZOMBIE_SEPARATION_DISTANCE,
46
50
  ZOMBIE_SIGHT_RANGE,
47
51
  ZOMBIE_SPEED,
52
+ ZOMBIE_TRACKER_FAR_SCENT_RADIUS,
53
+ ZOMBIE_TRACKER_CROWD_BAND_LENGTH,
54
+ ZOMBIE_TRACKER_CROWD_BAND_WIDTH,
55
+ ZOMBIE_TRACKER_CROWD_COUNT,
56
+ ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS,
57
+ ZOMBIE_TRACKER_RELOCK_DELAY_MS,
48
58
  ZOMBIE_TRACKER_SCAN_INTERVAL_MS,
49
- ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER,
50
59
  ZOMBIE_TRACKER_SCENT_RADIUS,
51
60
  ZOMBIE_TRACKER_SCENT_TOP_K,
52
61
  ZOMBIE_TRACKER_SIGHT_RANGE,
53
62
  ZOMBIE_TRACKER_WANDER_INTERVAL_MS,
54
63
  ZOMBIE_WALL_DAMAGE,
55
- ZOMBIE_WALL_FOLLOW_LOST_WALL_MS,
56
- ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG,
57
- ZOMBIE_WALL_FOLLOW_PROBE_STEP,
58
- ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
59
- ZOMBIE_WALL_FOLLOW_TARGET_GAP,
64
+ ZOMBIE_WALL_HUG_LOST_WALL_MS,
65
+ ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG,
66
+ ZOMBIE_WALL_HUG_PROBE_STEP,
67
+ ZOMBIE_WALL_HUG_SENSOR_DISTANCE,
68
+ ZOMBIE_WALL_HUG_TARGET_GAP,
60
69
  ZOMBIE_WANDER_INTERVAL_MS,
61
70
  )
62
71
  from .gameplay.constants import FOOTPRINT_STEP_DISTANCE
63
72
  from .models import Footprint
64
- from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
65
73
  from .render_assets import (
66
74
  EnvironmentPalette,
67
75
  angle_bin_from_vector,
@@ -71,6 +79,7 @@ from .render_assets import (
71
79
  build_flashlight_surface,
72
80
  build_fuel_can_surface,
73
81
  build_player_directional_surfaces,
82
+ build_rubble_wall_surface,
74
83
  build_shoes_surface,
75
84
  build_survivor_directional_surfaces,
76
85
  build_zombie_directional_surfaces,
@@ -79,10 +88,13 @@ from .render_assets import (
79
88
  paint_car_surface,
80
89
  paint_steel_beam_surface,
81
90
  paint_wall_surface,
91
+ rubble_offset_for_size,
82
92
  resolve_car_color,
83
93
  resolve_steel_beam_colors,
84
94
  resolve_wall_colors,
95
+ RUBBLE_ROTATION_DEG,
85
96
  )
97
+ from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
86
98
  from .rng import get_rng
87
99
  from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
88
100
  from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
@@ -90,6 +102,31 @@ from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
90
102
  RNG = get_rng()
91
103
 
92
104
 
105
+ def _can_humanoid_jump(
106
+ x: float,
107
+ y: float,
108
+ dx: float,
109
+ dy: float,
110
+ jump_range: float,
111
+ cell_size: int,
112
+ walkable_cells: Iterable[tuple[int, int]],
113
+ ) -> bool:
114
+ """Accurately check if a jump is possible in the given movement direction."""
115
+ move_len = math.hypot(dx, dy)
116
+ if move_len <= 0:
117
+ return False
118
+ look_ahead_x = x + (dx / move_len) * jump_range
119
+ look_ahead_y = y + (dy / move_len) * jump_range
120
+ lax, lay = int(look_ahead_x // cell_size), int(look_ahead_y // cell_size)
121
+ return (lax, lay) in walkable_cells
122
+
123
+
124
+ def _get_jump_scale(elapsed_ms: int, duration_ms: int, scale_max: float) -> float:
125
+ """Calculate the parabolic scale factor for a jump."""
126
+ t = max(0.0, min(1.0, elapsed_ms / duration_ms))
127
+ return 1.0 + scale_max * (4 * t * (1 - t))
128
+
129
+
93
130
  class Wall(pygame.sprite.Sprite):
94
131
  def __init__(
95
132
  self: Self,
@@ -188,6 +225,67 @@ class Wall(pygame.sprite.Sprite):
188
225
  self._update_color()
189
226
 
190
227
 
228
+ class RubbleWall(Wall):
229
+ def __init__(
230
+ self: Self,
231
+ x: int,
232
+ y: int,
233
+ width: int,
234
+ height: int,
235
+ *,
236
+ health: int = INTERNAL_WALL_HEALTH,
237
+ palette: EnvironmentPalette | None = None,
238
+ palette_category: str = "inner_wall",
239
+ bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
240
+ rubble_rotation_deg: float | None = None,
241
+ rubble_offset_px: int | None = None,
242
+ on_destroy: Callable[[Self], None] | None = None,
243
+ ) -> None:
244
+ self._rubble_rotation_deg = (
245
+ RUBBLE_ROTATION_DEG if rubble_rotation_deg is None else rubble_rotation_deg
246
+ )
247
+ base_size = max(1, min(width, height))
248
+ self._rubble_offset_px = (
249
+ rubble_offset_for_size(base_size)
250
+ if rubble_offset_px is None
251
+ else rubble_offset_px
252
+ )
253
+ super().__init__(
254
+ x,
255
+ y,
256
+ width,
257
+ height,
258
+ health=health,
259
+ palette=palette,
260
+ palette_category=palette_category,
261
+ bevel_depth=bevel_depth,
262
+ bevel_mask=(False, False, False, False),
263
+ draw_bottom_side=False,
264
+ on_destroy=on_destroy,
265
+ )
266
+
267
+ def _update_color(self: Self) -> None:
268
+ if self.health <= 0:
269
+ health_ratio = 0.0
270
+ else:
271
+ health_ratio = max(0.0, self.health / self.max_health)
272
+ fill_color, border_color = resolve_wall_colors(
273
+ health_ratio=health_ratio,
274
+ palette_category=self.palette_category,
275
+ palette=self.palette,
276
+ )
277
+ rubble_surface = build_rubble_wall_surface(
278
+ self.image.get_width(),
279
+ fill_color=fill_color,
280
+ border_color=border_color,
281
+ angle_deg=self._rubble_rotation_deg,
282
+ offset_px=self._rubble_offset_px,
283
+ bevel_depth=self.bevel_depth,
284
+ )
285
+ self.image.fill((0, 0, 0, 0))
286
+ self.image.blit(rubble_surface, (0, 0))
287
+
288
+
191
289
  class SteelBeam(pygame.sprite.Sprite):
192
290
  """Single-cell obstacle that behaves like a tougher internal wall."""
193
291
 
@@ -248,6 +346,7 @@ MovementStrategy = Callable[
248
346
  "Zombie",
249
347
  tuple[int, int],
250
348
  list[Wall],
349
+ Iterable["Zombie"],
251
350
  list[Footprint],
252
351
  int,
253
352
  int,
@@ -596,6 +695,9 @@ class Player(pygame.sprite.Sprite):
596
695
  self.in_car = False
597
696
  self.x = float(self.rect.centerx)
598
697
  self.y = float(self.rect.centery)
698
+ self.jump_start_at = 0
699
+ self.jump_duration = JUMP_DURATION_MS
700
+ self.is_jumping = False
599
701
 
600
702
  def move(
601
703
  self: Self,
@@ -607,6 +709,8 @@ class Player(pygame.sprite.Sprite):
607
709
  cell_size: int | None = None,
608
710
  level_width: int | None = None,
609
711
  level_height: int | None = None,
712
+ pitfall_cells: set[tuple[int, int]] | None = None,
713
+ walkable_cells: list[tuple[int, int]] | None = None,
610
714
  ) -> None:
611
715
  if self.in_car:
612
716
  return
@@ -614,6 +718,28 @@ class Player(pygame.sprite.Sprite):
614
718
  if level_width is None or level_height is None:
615
719
  raise ValueError("level_width/level_height are required for movement")
616
720
 
721
+ now = pygame.time.get_ticks()
722
+ if self.is_jumping:
723
+ elapsed = now - self.jump_start_at
724
+ if elapsed >= self.jump_duration:
725
+ self.is_jumping = False
726
+ self._update_image_scale(1.0)
727
+ else:
728
+ self._update_image_scale(
729
+ _get_jump_scale(elapsed, self.jump_duration, JUMP_SCALE_MAX)
730
+ )
731
+
732
+ # Pre-calculate jump possibility based on actual movement vector
733
+ can_jump_now = (
734
+ not self.is_jumping
735
+ and pitfall_cells
736
+ and cell_size
737
+ and walkable_cells
738
+ and _can_humanoid_jump(
739
+ self.x, self.y, dx, dy, PLAYER_JUMP_RANGE, cell_size, walkable_cells
740
+ )
741
+ )
742
+
617
743
  inner_wall_hit = False
618
744
  inner_wall_cell: tuple[int, int] | None = None
619
745
 
@@ -627,18 +753,32 @@ class Player(pygame.sprite.Sprite):
627
753
  wall_index=wall_index,
628
754
  cell_size=cell_size,
629
755
  )
630
- if hit_list_x:
631
- damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_x))
632
- for wall in hit_list_x:
633
- if wall.alive():
634
- wall._take_damage(amount=damage)
635
- if _is_inner_wall(wall):
636
- inner_wall_hit = True
637
- if inner_wall_cell is None and cell_size:
638
- inner_wall_cell = (
639
- int(wall.rect.centerx // cell_size),
640
- int(wall.rect.centery // cell_size),
641
- )
756
+ blocked_by_pitfall = False
757
+ if not self.is_jumping and pitfall_cells and cell_size:
758
+ cx, cy = (
759
+ int(self.rect.centerx // cell_size),
760
+ int(self.rect.centery // cell_size),
761
+ )
762
+ if (cx, cy) in pitfall_cells:
763
+ if can_jump_now:
764
+ self.is_jumping = True
765
+ self.jump_start_at = now
766
+ else:
767
+ blocked_by_pitfall = True
768
+
769
+ if hit_list_x or blocked_by_pitfall:
770
+ if hit_list_x:
771
+ damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_x))
772
+ for wall in hit_list_x:
773
+ if wall.alive():
774
+ wall._take_damage(amount=damage)
775
+ if _is_inner_wall(wall):
776
+ inner_wall_hit = True
777
+ if inner_wall_cell is None and cell_size:
778
+ inner_wall_cell = (
779
+ int(wall.rect.centerx // cell_size),
780
+ int(wall.rect.centery // cell_size),
781
+ )
642
782
  self.x -= dx * 1.5
643
783
  self.rect.centerx = int(self.x)
644
784
 
@@ -652,18 +792,32 @@ class Player(pygame.sprite.Sprite):
652
792
  wall_index=wall_index,
653
793
  cell_size=cell_size,
654
794
  )
655
- if hit_list_y:
656
- damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_y))
657
- for wall in hit_list_y:
658
- if wall.alive():
659
- wall._take_damage(amount=damage)
660
- if _is_inner_wall(wall):
661
- inner_wall_hit = True
662
- if inner_wall_cell is None and cell_size:
663
- inner_wall_cell = (
664
- int(wall.rect.centerx // cell_size),
665
- int(wall.rect.centery // cell_size),
666
- )
795
+ blocked_by_pitfall = False
796
+ if not self.is_jumping and pitfall_cells and cell_size:
797
+ cx, cy = (
798
+ int(self.rect.centerx // cell_size),
799
+ int(self.rect.centery // cell_size),
800
+ )
801
+ if (cx, cy) in pitfall_cells:
802
+ if can_jump_now:
803
+ self.is_jumping = True
804
+ self.jump_start_at = now
805
+ else:
806
+ blocked_by_pitfall = True
807
+
808
+ if hit_list_y or blocked_by_pitfall:
809
+ if hit_list_y:
810
+ damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_y))
811
+ for wall in hit_list_y:
812
+ if wall.alive():
813
+ wall._take_damage(amount=damage)
814
+ if _is_inner_wall(wall):
815
+ inner_wall_hit = True
816
+ if inner_wall_cell is None and cell_size:
817
+ inner_wall_cell = (
818
+ int(wall.rect.centerx // cell_size),
819
+ int(wall.rect.centery // cell_size),
820
+ )
667
821
  self.y -= dy * 1.5
668
822
  self.rect.centery = int(self.y)
669
823
 
@@ -672,6 +826,19 @@ class Player(pygame.sprite.Sprite):
672
826
  self.inner_wall_cell = inner_wall_cell
673
827
  self._update_facing_for_bump(inner_wall_hit)
674
828
 
829
+ def _update_image_scale(self: Self, scale: float) -> None:
830
+ """Apply scaling to the current directional image."""
831
+ base_img = self.directional_images[self.facing_bin]
832
+ if scale == 1.0:
833
+ self.image = base_img
834
+ else:
835
+ w, h = base_img.get_size()
836
+ self.image = pygame.transform.scale(
837
+ base_img, (int(w * scale), int(h * scale))
838
+ )
839
+ old_center = self.rect.center
840
+ self.rect = self.image.get_rect(center=old_center)
841
+
675
842
  def update_facing_from_input(self: Self, dx: float, dy: float) -> None:
676
843
  if self.in_car:
677
844
  return
@@ -732,6 +899,9 @@ class Survivor(pygame.sprite.Sprite):
732
899
  self.y = float(self.rect.centery)
733
900
  self.following = False
734
901
  self.rescued = False
902
+ self.jump_start_at = 0
903
+ self.jump_duration = JUMP_DURATION_MS
904
+ self.is_jumping = False
735
905
 
736
906
  def set_following(self: Self) -> None:
737
907
  if self.is_buddy and not self.rescued:
@@ -757,6 +927,8 @@ class Survivor(pygame.sprite.Sprite):
757
927
  wall_index: WallIndex | None = None,
758
928
  cell_size: int | None = None,
759
929
  wall_cells: set[tuple[int, int]] | None = None,
930
+ pitfall_cells: set[tuple[int, int]] | None = None,
931
+ walkable_cells: list[tuple[int, int]] | None = None,
760
932
  bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
761
933
  | None = None,
762
934
  grid_cols: int | None = None,
@@ -767,6 +939,18 @@ class Survivor(pygame.sprite.Sprite):
767
939
  ) -> None:
768
940
  if level_width is None or level_height is None:
769
941
  raise ValueError("level_width/level_height are required for movement")
942
+
943
+ now = pygame.time.get_ticks()
944
+ if self.is_jumping:
945
+ elapsed = now - self.jump_start_at
946
+ if elapsed >= self.jump_duration:
947
+ self.is_jumping = False
948
+ self._update_image_scale(1.0)
949
+ else:
950
+ self._update_image_scale(
951
+ _get_jump_scale(elapsed, self.jump_duration, JUMP_SCALE_MAX)
952
+ )
953
+
770
954
  if self.is_buddy:
771
955
  if self.rescued or not self.following:
772
956
  self.rect.center = (int(self.x), int(self.y))
@@ -812,36 +996,82 @@ class Survivor(pygame.sprite.Sprite):
812
996
  self._update_input_facing(move_x, move_y)
813
997
  inner_wall_hit = False
814
998
 
999
+ # Pre-calculate jump possibility for Buddy
1000
+ can_jump_now = (
1001
+ not self.is_jumping
1002
+ and pitfall_cells
1003
+ and cell_size
1004
+ and walkable_cells
1005
+ and _can_humanoid_jump(
1006
+ self.x,
1007
+ self.y,
1008
+ move_x,
1009
+ move_y,
1010
+ SURVIVOR_JUMP_RANGE,
1011
+ cell_size,
1012
+ walkable_cells,
1013
+ )
1014
+ )
1015
+
815
1016
  if move_x:
816
1017
  self.x += move_x
817
1018
  self.rect.centerx = int(self.x)
818
- wall = spritecollideany_walls(
1019
+ hit_wall = spritecollideany_walls(
819
1020
  self,
820
1021
  walls,
821
1022
  wall_index=wall_index,
822
1023
  cell_size=cell_size,
823
1024
  )
824
- if wall:
825
- if wall.alive():
826
- wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
827
- if _is_inner_wall(wall):
828
- inner_wall_hit = True
1025
+ blocked_by_pitfall = False
1026
+ if not self.is_jumping and pitfall_cells and cell_size:
1027
+ cx, cy = (
1028
+ int(self.rect.centerx // cell_size),
1029
+ int(self.rect.centery // cell_size),
1030
+ )
1031
+ if (cx, cy) in pitfall_cells:
1032
+ if can_jump_now:
1033
+ self.is_jumping = True
1034
+ self.jump_start_at = now
1035
+ else:
1036
+ blocked_by_pitfall = True
1037
+
1038
+ if hit_wall or blocked_by_pitfall:
1039
+ if hit_wall:
1040
+ if hit_wall.alive():
1041
+ hit_wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
1042
+ if _is_inner_wall(hit_wall):
1043
+ inner_wall_hit = True
829
1044
  self.x -= move_x
830
1045
  self.rect.centerx = int(self.x)
1046
+
831
1047
  if move_y:
832
1048
  self.y += move_y
833
1049
  self.rect.centery = int(self.y)
834
- wall = spritecollideany_walls(
1050
+ hit_wall = spritecollideany_walls(
835
1051
  self,
836
1052
  walls,
837
1053
  wall_index=wall_index,
838
1054
  cell_size=cell_size,
839
1055
  )
840
- if wall:
841
- if wall.alive():
842
- wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
843
- if _is_inner_wall(wall):
844
- inner_wall_hit = True
1056
+ blocked_by_pitfall = False
1057
+ if not self.is_jumping and pitfall_cells and cell_size:
1058
+ cx, cy = (
1059
+ int(self.rect.centerx // cell_size),
1060
+ int(self.rect.centery // cell_size),
1061
+ )
1062
+ if (cx, cy) in pitfall_cells:
1063
+ if can_jump_now:
1064
+ self.is_jumping = True
1065
+ self.jump_start_at = now
1066
+ else:
1067
+ blocked_by_pitfall = True
1068
+
1069
+ if hit_wall or blocked_by_pitfall:
1070
+ if hit_wall:
1071
+ if hit_wall.alive():
1072
+ hit_wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
1073
+ if _is_inner_wall(hit_wall):
1074
+ inner_wall_hit = True
845
1075
  self.y -= move_y
846
1076
  self.rect.centery = int(self.y)
847
1077
 
@@ -877,6 +1107,23 @@ class Survivor(pygame.sprite.Sprite):
877
1107
 
878
1108
  self._update_input_facing(move_x, move_y)
879
1109
 
1110
+ # Pre-calculate jump possibility for normal Survivor
1111
+ can_jump_now = (
1112
+ not self.is_jumping
1113
+ and pitfall_cells
1114
+ and cell_size
1115
+ and walkable_cells
1116
+ and _can_humanoid_jump(
1117
+ self.x,
1118
+ self.y,
1119
+ move_x,
1120
+ move_y,
1121
+ SURVIVOR_JUMP_RANGE,
1122
+ cell_size,
1123
+ walkable_cells,
1124
+ )
1125
+ )
1126
+
880
1127
  if (
881
1128
  cell_size is not None
882
1129
  and wall_cells is not None
@@ -898,29 +1145,70 @@ class Survivor(pygame.sprite.Sprite):
898
1145
  if move_x:
899
1146
  self.x += move_x
900
1147
  self.rect.centerx = int(self.x)
901
- if spritecollideany_walls(
1148
+ hit_by_wall = spritecollideany_walls(
902
1149
  self,
903
1150
  walls,
904
1151
  wall_index=wall_index,
905
1152
  cell_size=cell_size,
906
- ):
1153
+ )
1154
+ blocked_by_pitfall = False
1155
+ if not self.is_jumping and pitfall_cells and cell_size:
1156
+ cx, cy = (
1157
+ int(self.rect.centerx // cell_size),
1158
+ int(self.rect.centery // cell_size),
1159
+ )
1160
+ if (cx, cy) in pitfall_cells:
1161
+ if can_jump_now:
1162
+ self.is_jumping = True
1163
+ self.jump_start_at = now
1164
+ else:
1165
+ blocked_by_pitfall = True
1166
+
1167
+ if hit_by_wall or blocked_by_pitfall:
907
1168
  self.x -= move_x
908
1169
  self.rect.centerx = int(self.x)
909
1170
  if move_y:
910
1171
  self.y += move_y
911
1172
  self.rect.centery = int(self.y)
912
- if spritecollideany_walls(
1173
+ hit_by_wall = spritecollideany_walls(
913
1174
  self,
914
1175
  walls,
915
1176
  wall_index=wall_index,
916
1177
  cell_size=cell_size,
917
- ):
1178
+ )
1179
+ blocked_by_pitfall = False
1180
+ if not self.is_jumping and pitfall_cells and cell_size:
1181
+ cx, cy = (
1182
+ int(self.rect.centerx // cell_size),
1183
+ int(self.rect.centery // cell_size),
1184
+ )
1185
+ if (cx, cy) in pitfall_cells:
1186
+ if can_jump_now:
1187
+ self.is_jumping = True
1188
+ self.jump_start_at = now
1189
+ else:
1190
+ blocked_by_pitfall = True
1191
+
1192
+ if hit_by_wall or blocked_by_pitfall:
918
1193
  self.y -= move_y
919
1194
  self.rect.centery = int(self.y)
920
1195
 
921
1196
  self.rect.center = (int(self.x), int(self.y))
922
1197
  self._update_facing_for_bump(False)
923
1198
 
1199
+ def _update_image_scale(self: Self, scale: float) -> None:
1200
+ """Apply scaling to the current directional image."""
1201
+ base_img = self.directional_images[self.facing_bin]
1202
+ if scale == 1.0:
1203
+ self.image = base_img
1204
+ else:
1205
+ w, h = base_img.get_size()
1206
+ self.image = pygame.transform.scale(
1207
+ base_img, (int(w * scale), int(h * scale))
1208
+ )
1209
+ old_center = self.rect.center
1210
+ self.rect = self.image.get_rect(center=old_center)
1211
+
924
1212
  def _update_input_facing(self: Self, dx: float, dy: float) -> None:
925
1213
  new_bin = angle_bin_from_vector(dx, dy)
926
1214
  if new_bin is None:
@@ -972,6 +1260,7 @@ def _zombie_tracker_movement(
972
1260
  zombie: Zombie,
973
1261
  player_center: tuple[int, int],
974
1262
  walls: list[Wall],
1263
+ nearby_zombies: Iterable[Zombie],
975
1264
  footprints: list[Footprint],
976
1265
  cell_size: int,
977
1266
  grid_cols: int,
@@ -980,6 +1269,22 @@ def _zombie_tracker_movement(
980
1269
  ) -> tuple[float, float]:
981
1270
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
982
1271
  if not is_in_sight:
1272
+ if _zombie_tracker_is_crowded(zombie, nearby_zombies):
1273
+ last_target_time = zombie.tracker_target_time
1274
+ if last_target_time is None:
1275
+ last_target_time = pygame.time.get_ticks()
1276
+ zombie.tracker_relock_after_time = (
1277
+ last_target_time + ZOMBIE_TRACKER_RELOCK_DELAY_MS
1278
+ )
1279
+ zombie.tracker_target_pos = None
1280
+ return _zombie_wander_move(
1281
+ zombie,
1282
+ walls,
1283
+ cell_size=cell_size,
1284
+ grid_cols=grid_cols,
1285
+ grid_rows=grid_rows,
1286
+ outer_wall_cells=outer_wall_cells,
1287
+ )
983
1288
  _zombie_update_tracker_target(zombie, footprints, walls)
984
1289
  if zombie.tracker_target_pos is not None:
985
1290
  return _zombie_move_toward(zombie, zombie.tracker_target_pos)
@@ -998,6 +1303,7 @@ def _zombie_wander_movement(
998
1303
  zombie: Zombie,
999
1304
  _player_center: tuple[int, int],
1000
1305
  walls: list[Wall],
1306
+ _nearby_zombies: Iterable[Zombie],
1001
1307
  _footprints: list[Footprint],
1002
1308
  cell_size: int,
1003
1309
  grid_cols: int,
@@ -1014,7 +1320,7 @@ def _zombie_wander_movement(
1014
1320
  )
1015
1321
 
1016
1322
 
1017
- def _zombie_wall_follow_has_wall(
1323
+ def _zombie_wall_hug_has_wall(
1018
1324
  zombie: Zombie,
1019
1325
  walls: list[Wall],
1020
1326
  angle: float,
@@ -1034,13 +1340,13 @@ def _zombie_wall_follow_has_wall(
1034
1340
  )
1035
1341
 
1036
1342
 
1037
- def _zombie_wall_follow_wall_distance(
1343
+ def _zombie_wall_hug_wall_distance(
1038
1344
  zombie: Zombie,
1039
1345
  walls: list[Wall],
1040
1346
  angle: float,
1041
1347
  max_distance: float,
1042
1348
  *,
1043
- step: float = ZOMBIE_WALL_FOLLOW_PROBE_STEP,
1349
+ step: float = ZOMBIE_WALL_HUG_PROBE_STEP,
1044
1350
  ) -> float:
1045
1351
  direction_x = math.cos(angle)
1046
1352
  direction_y = math.sin(angle)
@@ -1066,10 +1372,11 @@ def _zombie_wall_follow_wall_distance(
1066
1372
  return max_distance
1067
1373
 
1068
1374
 
1069
- def _zombie_wall_follow_movement(
1375
+ def _zombie_wall_hug_movement(
1070
1376
  zombie: Zombie,
1071
1377
  player_center: tuple[int, int],
1072
1378
  walls: list[Wall],
1379
+ _nearby_zombies: Iterable[Zombie],
1073
1380
  _footprints: list[Footprint],
1074
1381
  cell_size: int,
1075
1382
  grid_cols: int,
@@ -1077,21 +1384,21 @@ def _zombie_wall_follow_movement(
1077
1384
  outer_wall_cells: set[tuple[int, int]] | None,
1078
1385
  ) -> tuple[float, float]:
1079
1386
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
1080
- if zombie.wall_follow_angle is None:
1081
- zombie.wall_follow_angle = zombie.wander_angle
1082
- if zombie.wall_follow_side == 0:
1083
- sensor_distance = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius
1084
- forward_angle = zombie.wall_follow_angle
1085
- probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
1387
+ if zombie.wall_hug_angle is None:
1388
+ zombie.wall_hug_angle = zombie.wander_angle
1389
+ if zombie.wall_hug_side == 0:
1390
+ sensor_distance = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius
1391
+ forward_angle = zombie.wall_hug_angle
1392
+ probe_offset = math.radians(ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG)
1086
1393
  left_angle = forward_angle + probe_offset
1087
1394
  right_angle = forward_angle - probe_offset
1088
- left_dist = _zombie_wall_follow_wall_distance(
1395
+ left_dist = _zombie_wall_hug_wall_distance(
1089
1396
  zombie, walls, left_angle, sensor_distance
1090
1397
  )
1091
- right_dist = _zombie_wall_follow_wall_distance(
1398
+ right_dist = _zombie_wall_hug_wall_distance(
1092
1399
  zombie, walls, right_angle, sensor_distance
1093
1400
  )
1094
- forward_dist = _zombie_wall_follow_wall_distance(
1401
+ forward_dist = _zombie_wall_hug_wall_distance(
1095
1402
  zombie, walls, forward_angle, sensor_distance
1096
1403
  )
1097
1404
  left_wall = left_dist < sensor_distance
@@ -1099,15 +1406,15 @@ def _zombie_wall_follow_movement(
1099
1406
  forward_wall = forward_dist < sensor_distance
1100
1407
  if left_wall or right_wall or forward_wall:
1101
1408
  if left_wall and not right_wall:
1102
- zombie.wall_follow_side = 1.0
1409
+ zombie.wall_hug_side = 1.0
1103
1410
  elif right_wall and not left_wall:
1104
- zombie.wall_follow_side = -1.0
1411
+ zombie.wall_hug_side = -1.0
1105
1412
  elif left_wall and right_wall:
1106
- zombie.wall_follow_side = 1.0 if left_dist <= right_dist else -1.0
1413
+ zombie.wall_hug_side = 1.0 if left_dist <= right_dist else -1.0
1107
1414
  else:
1108
- zombie.wall_follow_side = RNG.choice([-1.0, 1.0])
1109
- zombie.wall_follow_last_wall_time = pygame.time.get_ticks()
1110
- zombie.wall_follow_last_side_has_wall = left_wall or right_wall
1415
+ zombie.wall_hug_side = RNG.choice([-1.0, 1.0])
1416
+ zombie.wall_hug_last_wall_time = pygame.time.get_ticks()
1417
+ zombie.wall_hug_last_side_has_wall = left_wall or right_wall
1111
1418
  else:
1112
1419
  if is_in_sight:
1113
1420
  return _zombie_move_toward(zombie, player_center)
@@ -1120,53 +1427,53 @@ def _zombie_wall_follow_movement(
1120
1427
  outer_wall_cells=outer_wall_cells,
1121
1428
  )
1122
1429
 
1123
- sensor_distance = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius
1124
- probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
1125
- side_angle = zombie.wall_follow_angle + zombie.wall_follow_side * probe_offset
1126
- side_dist = _zombie_wall_follow_wall_distance(
1430
+ sensor_distance = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius
1431
+ probe_offset = math.radians(ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG)
1432
+ side_angle = zombie.wall_hug_angle + zombie.wall_hug_side * probe_offset
1433
+ side_dist = _zombie_wall_hug_wall_distance(
1127
1434
  zombie, walls, side_angle, sensor_distance
1128
1435
  )
1129
- forward_dist = _zombie_wall_follow_wall_distance(
1130
- zombie, walls, zombie.wall_follow_angle, sensor_distance
1436
+ forward_dist = _zombie_wall_hug_wall_distance(
1437
+ zombie, walls, zombie.wall_hug_angle, sensor_distance
1131
1438
  )
1132
1439
  side_has_wall = side_dist < sensor_distance
1133
1440
  forward_has_wall = forward_dist < sensor_distance
1134
1441
  now = pygame.time.get_ticks()
1135
1442
  wall_recent = (
1136
- zombie.wall_follow_last_wall_time is not None
1137
- and now - zombie.wall_follow_last_wall_time <= ZOMBIE_WALL_FOLLOW_LOST_WALL_MS
1443
+ zombie.wall_hug_last_wall_time is not None
1444
+ and now - zombie.wall_hug_last_wall_time <= ZOMBIE_WALL_HUG_LOST_WALL_MS
1138
1445
  )
1139
1446
  if is_in_sight:
1140
1447
  return _zombie_move_toward(zombie, player_center)
1141
1448
 
1142
1449
  turn_step = math.radians(5)
1143
1450
  if side_has_wall or forward_has_wall:
1144
- zombie.wall_follow_last_wall_time = now
1451
+ zombie.wall_hug_last_wall_time = now
1145
1452
  if side_has_wall:
1146
- zombie.wall_follow_last_side_has_wall = True
1147
- gap_error = ZOMBIE_WALL_FOLLOW_TARGET_GAP - side_dist
1453
+ zombie.wall_hug_last_side_has_wall = True
1454
+ gap_error = ZOMBIE_WALL_HUG_TARGET_GAP - side_dist
1148
1455
  if abs(gap_error) > 0.1:
1149
- ratio = min(1.0, abs(gap_error) / ZOMBIE_WALL_FOLLOW_TARGET_GAP)
1456
+ ratio = min(1.0, abs(gap_error) / ZOMBIE_WALL_HUG_TARGET_GAP)
1150
1457
  turn = turn_step * ratio
1151
1458
  if gap_error > 0:
1152
- zombie.wall_follow_angle -= zombie.wall_follow_side * turn
1459
+ zombie.wall_hug_angle -= zombie.wall_hug_side * turn
1153
1460
  else:
1154
- zombie.wall_follow_angle += zombie.wall_follow_side * turn
1155
- if forward_dist < ZOMBIE_WALL_FOLLOW_TARGET_GAP:
1156
- zombie.wall_follow_angle -= zombie.wall_follow_side * (turn_step * 1.5)
1461
+ zombie.wall_hug_angle += zombie.wall_hug_side * turn
1462
+ if forward_dist < ZOMBIE_WALL_HUG_TARGET_GAP:
1463
+ zombie.wall_hug_angle -= zombie.wall_hug_side * (turn_step * 1.5)
1157
1464
  else:
1158
- zombie.wall_follow_last_side_has_wall = False
1465
+ zombie.wall_hug_last_side_has_wall = False
1159
1466
  if forward_has_wall:
1160
- zombie.wall_follow_angle -= zombie.wall_follow_side * turn_step
1467
+ zombie.wall_hug_angle -= zombie.wall_hug_side * turn_step
1161
1468
  elif wall_recent:
1162
- zombie.wall_follow_angle += zombie.wall_follow_side * (turn_step * 0.75)
1469
+ zombie.wall_hug_angle += zombie.wall_hug_side * (turn_step * 0.75)
1163
1470
  else:
1164
- zombie.wall_follow_angle += zombie.wall_follow_side * (math.pi / 2.0)
1165
- zombie.wall_follow_side = 0.0
1166
- zombie.wall_follow_angle %= math.tau
1471
+ zombie.wall_hug_angle += zombie.wall_hug_side * (math.pi / 2.0)
1472
+ zombie.wall_hug_side = 0.0
1473
+ zombie.wall_hug_angle %= math.tau
1167
1474
 
1168
- move_x = math.cos(zombie.wall_follow_angle) * zombie.speed
1169
- move_y = math.sin(zombie.wall_follow_angle) * zombie.speed
1475
+ move_x = math.cos(zombie.wall_hug_angle) * zombie.speed
1476
+ move_y = math.sin(zombie.wall_hug_angle) * zombie.speed
1170
1477
  return move_x, move_y
1171
1478
 
1172
1479
 
@@ -1174,6 +1481,7 @@ def _zombie_normal_movement(
1174
1481
  zombie: Zombie,
1175
1482
  player_center: tuple[int, int],
1176
1483
  walls: list[Wall],
1484
+ _nearby_zombies: Iterable[Zombie],
1177
1485
  _footprints: list[Footprint],
1178
1486
  cell_size: int,
1179
1487
  grid_cols: int,
@@ -1207,12 +1515,29 @@ def _zombie_update_tracker_target(
1207
1515
  return
1208
1516
  nearby: list[Footprint] = []
1209
1517
  last_target_time = zombie.tracker_target_time
1210
- scan_radius = ZOMBIE_TRACKER_SCENT_RADIUS * ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER
1518
+ far_radius_sq = ZOMBIE_TRACKER_FAR_SCENT_RADIUS * ZOMBIE_TRACKER_FAR_SCENT_RADIUS
1519
+ latest_fp_time: int | None = None
1520
+ relock_after = zombie.tracker_relock_after_time
1521
+ for fp in footprints:
1522
+ dx = fp.pos[0] - zombie.x
1523
+ dy = fp.pos[1] - zombie.y
1524
+ if dx * dx + dy * dy <= far_radius_sq:
1525
+ if latest_fp_time is None or fp.time > latest_fp_time:
1526
+ latest_fp_time = fp.time
1527
+ use_far_scan = last_target_time is None or (
1528
+ latest_fp_time is not None
1529
+ and latest_fp_time - last_target_time >= ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS
1530
+ )
1531
+ scan_radius = (
1532
+ ZOMBIE_TRACKER_FAR_SCENT_RADIUS if use_far_scan else ZOMBIE_TRACKER_SCENT_RADIUS
1533
+ )
1211
1534
  scent_radius_sq = scan_radius * scan_radius
1212
1535
  min_target_dist_sq = (FOOTPRINT_STEP_DISTANCE * 0.5) ** 2
1213
1536
  for fp in footprints:
1214
1537
  pos = fp.pos
1215
1538
  fp_time = fp.time
1539
+ if relock_after is not None and fp_time < relock_after:
1540
+ continue
1216
1541
  dx = pos[0] - zombie.x
1217
1542
  dy = pos[1] - zombie.y
1218
1543
  if dx * dx + dy * dy <= min_target_dist_sq:
@@ -1235,6 +1560,8 @@ def _zombie_update_tracker_target(
1235
1560
  if _line_of_sight_clear((zombie.x, zombie.y), pos, walls):
1236
1561
  zombie.tracker_target_pos = pos
1237
1562
  zombie.tracker_target_time = fp_time
1563
+ if relock_after is not None and fp_time >= relock_after:
1564
+ zombie.tracker_relock_after_time = None
1238
1565
  return
1239
1566
 
1240
1567
  if (
@@ -1255,9 +1582,45 @@ def _zombie_update_tracker_target(
1255
1582
  next_fp = candidates[0]
1256
1583
  zombie.tracker_target_pos = next_fp.pos
1257
1584
  zombie.tracker_target_time = next_fp.time
1585
+ if relock_after is not None and next_fp.time >= relock_after:
1586
+ zombie.tracker_relock_after_time = None
1258
1587
  return
1259
1588
 
1260
1589
 
1590
+ def _zombie_tracker_is_crowded(
1591
+ zombie: Zombie,
1592
+ nearby_zombies: Iterable[Zombie],
1593
+ ) -> bool:
1594
+ dx = zombie.last_move_dx
1595
+ dy = zombie.last_move_dy
1596
+ if abs(dx) <= 0.001 and abs(dy) <= 0.001:
1597
+ return False
1598
+ angle = math.atan2(dy, dx)
1599
+ angle_step = math.pi / 4.0
1600
+ angle_bin = int(round(angle / angle_step)) % 8
1601
+ dir_x = math.cos(angle_bin * angle_step)
1602
+ dir_y = math.sin(angle_bin * angle_step)
1603
+ perp_x = -dir_y
1604
+ perp_y = dir_x
1605
+ half_width = ZOMBIE_TRACKER_CROWD_BAND_WIDTH * 0.5
1606
+ length = ZOMBIE_TRACKER_CROWD_BAND_LENGTH
1607
+ count = 0
1608
+ for other in nearby_zombies:
1609
+ if other is zombie or not other.alive() or not other.tracker:
1610
+ continue
1611
+ offset_x = other.x - zombie.x
1612
+ offset_y = other.y - zombie.y
1613
+ forward = offset_x * dir_x + offset_y * dir_y
1614
+ if forward <= 0 or forward > length:
1615
+ continue
1616
+ side = offset_x * perp_x + offset_y * perp_y
1617
+ if abs(side) <= half_width:
1618
+ count += 1
1619
+ if count >= ZOMBIE_TRACKER_CROWD_COUNT:
1620
+ return True
1621
+ return False
1622
+
1623
+
1261
1624
  def _zombie_wander_move(
1262
1625
  zombie: Zombie,
1263
1626
  walls: list[Wall],
@@ -1354,7 +1717,7 @@ class Zombie(pygame.sprite.Sprite):
1354
1717
  *,
1355
1718
  speed: float = ZOMBIE_SPEED,
1356
1719
  tracker: bool = False,
1357
- wall_follower: bool = False,
1720
+ wall_hugging: bool = False,
1358
1721
  movement_strategy: MovementStrategy | None = None,
1359
1722
  aging_duration_frames: float = ZOMBIE_AGING_DURATION_FRAMES,
1360
1723
  ) -> None:
@@ -1362,7 +1725,7 @@ class Zombie(pygame.sprite.Sprite):
1362
1725
  self.radius = ZOMBIE_RADIUS
1363
1726
  self.facing_bin = 0
1364
1727
  self.tracker = tracker
1365
- self.wall_follower = wall_follower
1728
+ self.wall_hugging = wall_hugging
1366
1729
  self.carbonized = False
1367
1730
  self.directional_images = build_zombie_directional_surfaces(
1368
1731
  self.radius,
@@ -1383,8 +1746,8 @@ class Zombie(pygame.sprite.Sprite):
1383
1746
  if movement_strategy is None:
1384
1747
  if tracker:
1385
1748
  movement_strategy = _zombie_tracker_movement
1386
- elif wall_follower:
1387
- movement_strategy = _zombie_wall_follow_movement
1749
+ elif wall_hugging:
1750
+ movement_strategy = _zombie_wall_hug_movement
1388
1751
  else:
1389
1752
  movement_strategy = _zombie_normal_movement
1390
1753
  self.movement_strategy = movement_strategy
@@ -1392,11 +1755,12 @@ class Zombie(pygame.sprite.Sprite):
1392
1755
  self.tracker_target_time: int | None = None
1393
1756
  self.tracker_last_scan_time = 0
1394
1757
  self.tracker_scan_interval_ms = ZOMBIE_TRACKER_SCAN_INTERVAL_MS
1395
- self.wall_follow_side = RNG.choice([-1.0, 1.0]) if wall_follower else 0.0
1396
- self.wall_follow_angle = RNG.uniform(0, math.tau) if wall_follower else None
1397
- self.wall_follow_last_wall_time: int | None = None
1398
- self.wall_follow_last_side_has_wall = False
1399
- self.wall_follow_stuck_flag = False
1758
+ self.tracker_relock_after_time: int | None = None
1759
+ self.wall_hug_side = RNG.choice([-1.0, 1.0]) if wall_hugging else 0.0
1760
+ self.wall_hug_angle = RNG.uniform(0, math.tau) if wall_hugging else None
1761
+ self.wall_hug_last_wall_time: int | None = None
1762
+ self.wall_hug_last_side_has_wall = False
1763
+ self.wall_hug_stuck_flag = False
1400
1764
  self.pos_history: list[tuple[float, float]] = []
1401
1765
  self.wander_angle = RNG.uniform(0, math.tau)
1402
1766
  self.wander_interval_ms = (
@@ -1406,6 +1770,8 @@ class Zombie(pygame.sprite.Sprite):
1406
1770
  self.wander_change_interval = max(
1407
1771
  0, self.wander_interval_ms + RNG.randint(-500, 500)
1408
1772
  )
1773
+ self.last_move_dx = 0.0
1774
+ self.last_move_dy = 0.0
1409
1775
 
1410
1776
  def _update_mode(
1411
1777
  self: Self, player_center: tuple[int, int], sight_range: float
@@ -1478,17 +1844,17 @@ class Zombie(pygame.sprite.Sprite):
1478
1844
  if closest is None:
1479
1845
  return move_x, move_y
1480
1846
 
1481
- if self.wall_follower:
1847
+ if self.wall_hugging:
1482
1848
  other_radius = float(closest.radius)
1483
1849
  bump_dist_sq = (self.radius + other_radius) ** 2
1484
1850
  if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
1485
- if self.wall_follow_angle is None:
1486
- self.wall_follow_angle = self.wander_angle
1487
- self.wall_follow_angle = (self.wall_follow_angle + math.pi) % math.tau
1488
- self.wall_follow_side *= -1.0
1851
+ if self.wall_hug_angle is None:
1852
+ self.wall_hug_angle = self.wander_angle
1853
+ self.wall_hug_angle = (self.wall_hug_angle + math.pi) % math.tau
1854
+ self.wall_hug_side *= -1.0
1489
1855
  return (
1490
- math.cos(self.wall_follow_angle) * self.speed,
1491
- math.sin(self.wall_follow_angle) * self.speed,
1856
+ math.cos(self.wall_hug_angle) * self.speed,
1857
+ math.sin(self.wall_hug_angle) * self.speed,
1492
1858
  )
1493
1859
 
1494
1860
  away_dx = next_x - closest.x
@@ -1530,9 +1896,9 @@ class Zombie(pygame.sprite.Sprite):
1530
1896
  def _apply_render_overlays(self: Self) -> None:
1531
1897
  base_surface = self.directional_images[self.facing_bin]
1532
1898
  needs_overlay = self.tracker or (
1533
- self.wall_follower
1534
- and self.wall_follow_side != 0
1535
- and self.wall_follow_last_side_has_wall
1899
+ self.wall_hugging
1900
+ and self.wall_hug_side != 0
1901
+ and self.wall_hug_last_side_has_wall
1536
1902
  )
1537
1903
  if not needs_overlay:
1538
1904
  self.image = base_surface
@@ -1547,11 +1913,11 @@ class Zombie(pygame.sprite.Sprite):
1547
1913
  color=ZOMBIE_NOSE_COLOR,
1548
1914
  )
1549
1915
  if (
1550
- self.wall_follower
1551
- and self.wall_follow_side != 0
1552
- and self.wall_follow_last_side_has_wall
1916
+ self.wall_hugging
1917
+ and self.wall_hug_side != 0
1918
+ and self.wall_hug_last_side_has_wall
1553
1919
  ):
1554
- side_sign = 1.0 if self.wall_follow_side > 0 else -1.0
1920
+ side_sign = 1.0 if self.wall_hug_side > 0 else -1.0
1555
1921
  hand_angle = angle_rad + side_sign * (math.pi / 2.0)
1556
1922
  draw_humanoid_hand(
1557
1923
  self.image,
@@ -1568,17 +1934,50 @@ class Zombie(pygame.sprite.Sprite):
1568
1934
  max_dist_sq = max(
1569
1935
  (self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history
1570
1936
  )
1571
- self.wall_follow_stuck_flag = max_dist_sq < 25
1572
- if not self.wall_follow_stuck_flag:
1937
+ self.wall_hug_stuck_flag = max_dist_sq < 25
1938
+ if not self.wall_hug_stuck_flag:
1573
1939
  return
1574
- if self.wall_follower:
1575
- if self.wall_follow_angle is None:
1576
- self.wall_follow_angle = self.wander_angle
1577
- self.wall_follow_angle = (self.wall_follow_angle + math.pi) % math.tau
1578
- self.wall_follow_side *= -1.0
1579
- self.wall_follow_stuck_flag = False
1940
+ if self.wall_hugging:
1941
+ if self.wall_hug_angle is None:
1942
+ self.wall_hug_angle = self.wander_angle
1943
+ self.wall_hug_angle = (self.wall_hug_angle + math.pi) % math.tau
1944
+ self.wall_hug_side *= -1.0
1945
+ self.wall_hug_stuck_flag = False
1580
1946
  self.pos_history = []
1581
1947
 
1948
+ def _avoid_pitfalls(
1949
+ self: Self,
1950
+ pitfall_cells: set[tuple[int, int]],
1951
+ cell_size: int,
1952
+ ) -> tuple[float, float]:
1953
+ if cell_size <= 0 or not pitfall_cells:
1954
+ return 0.0, 0.0
1955
+ cell_x = int(self.x // cell_size)
1956
+ cell_y = int(self.y // cell_size)
1957
+ search_cells = 1
1958
+ avoid_radius = cell_size * 1.25
1959
+ max_strength = self.speed * 0.5
1960
+ push_x = 0.0
1961
+ push_y = 0.0
1962
+ for cy in range(cell_y - search_cells, cell_y + search_cells + 1):
1963
+ for cx in range(cell_x - search_cells, cell_x + search_cells + 1):
1964
+ if (cx, cy) not in pitfall_cells:
1965
+ continue
1966
+ pit_x = (cx + 0.5) * cell_size
1967
+ pit_y = (cy + 0.5) * cell_size
1968
+ dx = self.x - pit_x
1969
+ dy = self.y - pit_y
1970
+ dist_sq = dx * dx + dy * dy
1971
+ if dist_sq <= 0:
1972
+ continue
1973
+ dist = math.sqrt(dist_sq)
1974
+ if dist >= avoid_radius:
1975
+ continue
1976
+ strength = (1.0 - dist / avoid_radius) * max_strength
1977
+ push_x += (dx / dist) * strength
1978
+ push_y += (dy / dist) * strength
1979
+ return push_x, push_y
1980
+
1582
1981
  def update(
1583
1982
  self: Self,
1584
1983
  player_center: tuple[int, int],
@@ -1593,6 +1992,7 @@ class Zombie(pygame.sprite.Sprite):
1593
1992
  level_height: int,
1594
1993
  outer_wall_cells: set[tuple[int, int]] | None = None,
1595
1994
  wall_cells: set[tuple[int, int]] | None = None,
1995
+ pitfall_cells: set[tuple[int, int]] | None = None,
1596
1996
  bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
1597
1997
  | None = None,
1598
1998
  ) -> None:
@@ -1609,13 +2009,18 @@ class Zombie(pygame.sprite.Sprite):
1609
2009
  self,
1610
2010
  player_center,
1611
2011
  walls,
2012
+ nearby_zombies,
1612
2013
  footprints or [],
1613
2014
  cell_size,
1614
2015
  grid_cols,
1615
2016
  grid_rows,
1616
2017
  outer_wall_cells,
1617
2018
  )
1618
- if dist_to_player_sq <= avoid_radius_sq or self.wall_follower:
2019
+ if pitfall_cells is not None:
2020
+ avoid_x, avoid_y = self._avoid_pitfalls(pitfall_cells, cell_size)
2021
+ move_x += avoid_x
2022
+ move_y += avoid_y
2023
+ if dist_to_player_sq <= avoid_radius_sq or self.wall_hugging:
1619
2024
  move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
1620
2025
  if wall_cells is not None:
1621
2026
  move_x, move_y = apply_tile_edge_nudge(
@@ -1631,6 +2036,8 @@ class Zombie(pygame.sprite.Sprite):
1631
2036
  )
1632
2037
  self._update_facing_from_movement(move_x, move_y)
1633
2038
  self._apply_render_overlays()
2039
+ self.last_move_dx = move_x
2040
+ self.last_move_dy = move_y
1634
2041
  final_x, final_y = self._handle_wall_collision(
1635
2042
  self.x + move_x, self.y + move_y, walls
1636
2043
  )
@@ -1723,6 +2130,8 @@ class Car(pygame.sprite.Sprite):
1723
2130
  walls: Iterable[Wall],
1724
2131
  *,
1725
2132
  walls_nearby: bool = False,
2133
+ cell_size: int | None = None,
2134
+ pitfall_cells: set[tuple[int, int]] | None = None,
1726
2135
  ) -> None:
1727
2136
  if self.health <= 0:
1728
2137
  return
@@ -1746,15 +2155,27 @@ class Car(pygame.sprite.Sprite):
1746
2155
  for wall in possible_walls:
1747
2156
  if _circle_wall_collision(car_center, self.collision_radius, wall):
1748
2157
  hit_walls.append(wall)
1749
- if hit_walls:
1750
- self._take_damage(CAR_WALL_DAMAGE)
1751
- hit_walls.sort(
1752
- key=lambda w: (w.rect.centery - self.y) ** 2
1753
- + (w.rect.centerx - self.x) ** 2
1754
- )
1755
- nearest_wall = hit_walls[0]
1756
- new_x += (self.x - nearest_wall.rect.centerx) * 1.2
1757
- new_y += (self.y - nearest_wall.rect.centery) * 1.2
2158
+
2159
+ in_pitfall = False
2160
+ if pitfall_cells and cell_size:
2161
+ cx, cy = int(new_x // cell_size), int(new_y // cell_size)
2162
+ if (cx, cy) in pitfall_cells:
2163
+ in_pitfall = True
2164
+
2165
+ if hit_walls or in_pitfall:
2166
+ if hit_walls:
2167
+ self._take_damage(CAR_WALL_DAMAGE)
2168
+ hit_walls.sort(
2169
+ key=lambda w: (w.rect.centery - self.y) ** 2
2170
+ + (w.rect.centerx - self.x) ** 2
2171
+ )
2172
+ nearest_wall = hit_walls[0]
2173
+ new_x += (self.x - nearest_wall.rect.centerx) * 1.2
2174
+ new_y += (self.y - nearest_wall.rect.centery) * 1.2
2175
+ else:
2176
+ # Pitfall only: bounce back from current position
2177
+ new_x = self.x - dx * 0.5
2178
+ new_y = self.y - dy * 0.5
1758
2179
 
1759
2180
  self.x = new_x
1760
2181
  self.y = new_y
@@ -1795,6 +2216,7 @@ def _car_body_radius(width: float, height: float) -> float:
1795
2216
 
1796
2217
  __all__ = [
1797
2218
  "Wall",
2219
+ "RubbleWall",
1798
2220
  "SteelBeam",
1799
2221
  "Camera",
1800
2222
  "Player",