zombie-escape 1.12.0__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.
Files changed (34) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/__main__.py +7 -0
  3. zombie_escape/colors.py +22 -14
  4. zombie_escape/entities.py +756 -147
  5. zombie_escape/entities_constants.py +35 -14
  6. zombie_escape/export_images.py +296 -0
  7. zombie_escape/gameplay/__init__.py +2 -1
  8. zombie_escape/gameplay/constants.py +6 -0
  9. zombie_escape/gameplay/footprints.py +4 -0
  10. zombie_escape/gameplay/interactions.py +19 -7
  11. zombie_escape/gameplay/layout.py +103 -34
  12. zombie_escape/gameplay/movement.py +85 -5
  13. zombie_escape/gameplay/spawn.py +139 -90
  14. zombie_escape/gameplay/state.py +18 -9
  15. zombie_escape/gameplay/survivors.py +13 -2
  16. zombie_escape/gameplay/utils.py +40 -21
  17. zombie_escape/level_blueprints.py +256 -19
  18. zombie_escape/locales/ui.en.json +12 -2
  19. zombie_escape/locales/ui.ja.json +12 -2
  20. zombie_escape/models.py +14 -7
  21. zombie_escape/render.py +149 -37
  22. zombie_escape/render_assets.py +419 -124
  23. zombie_escape/render_constants.py +27 -0
  24. zombie_escape/screens/game_over.py +14 -3
  25. zombie_escape/screens/gameplay.py +72 -14
  26. zombie_escape/screens/title.py +18 -7
  27. zombie_escape/stage_constants.py +51 -15
  28. zombie_escape/zombie_escape.py +24 -1
  29. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/METADATA +41 -15
  30. zombie_escape-1.13.1.dist-info/RECORD +49 -0
  31. zombie_escape-1.12.0.dist-info/RECORD +0 -47
  32. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/WHEEL +0 -0
  33. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/entry_points.txt +0 -0
  34. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/entities.py CHANGED
@@ -16,6 +16,7 @@ from pygame import rect
16
16
  from .entities_constants import (
17
17
  BUDDY_FOLLOW_SPEED,
18
18
  BUDDY_RADIUS,
19
+ BUDDY_WALL_DAMAGE,
19
20
  CAR_HEALTH,
20
21
  CAR_HEIGHT,
21
22
  CAR_SPEED,
@@ -26,16 +27,21 @@ from .entities_constants import (
26
27
  FLASHLIGHT_WIDTH,
27
28
  FUEL_CAN_HEIGHT,
28
29
  FUEL_CAN_WIDTH,
29
- SHOES_HEIGHT,
30
- SHOES_WIDTH,
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,
39
+ SHOES_HEIGHT,
40
+ SHOES_WIDTH,
36
41
  STEEL_BEAM_HEALTH,
37
42
  SURVIVOR_APPROACH_RADIUS,
38
43
  SURVIVOR_APPROACH_SPEED,
44
+ SURVIVOR_JUMP_RANGE,
39
45
  SURVIVOR_RADIUS,
40
46
  ZOMBIE_AGING_DURATION_FRAMES,
41
47
  ZOMBIE_AGING_MIN_SPEED_RATIO,
@@ -43,40 +49,52 @@ from .entities_constants import (
43
49
  ZOMBIE_SEPARATION_DISTANCE,
44
50
  ZOMBIE_SIGHT_RANGE,
45
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,
46
58
  ZOMBIE_TRACKER_SCAN_INTERVAL_MS,
47
- ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER,
48
59
  ZOMBIE_TRACKER_SCENT_RADIUS,
49
60
  ZOMBIE_TRACKER_SCENT_TOP_K,
50
61
  ZOMBIE_TRACKER_SIGHT_RANGE,
51
62
  ZOMBIE_TRACKER_WANDER_INTERVAL_MS,
52
63
  ZOMBIE_WALL_DAMAGE,
53
- ZOMBIE_WALL_FOLLOW_LOST_WALL_MS,
54
- ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG,
55
- ZOMBIE_WALL_FOLLOW_PROBE_STEP,
56
- ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
57
- 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,
58
69
  ZOMBIE_WANDER_INTERVAL_MS,
59
70
  )
60
71
  from .gameplay.constants import FOOTPRINT_STEP_DISTANCE
61
72
  from .models import Footprint
62
73
  from .render_assets import (
63
74
  EnvironmentPalette,
75
+ angle_bin_from_vector,
64
76
  build_beveled_polygon,
77
+ build_car_directional_surfaces,
65
78
  build_car_surface,
66
79
  build_flashlight_surface,
67
80
  build_fuel_can_surface,
81
+ build_player_directional_surfaces,
82
+ build_rubble_wall_surface,
68
83
  build_shoes_surface,
69
- build_player_surface,
70
- build_survivor_surface,
71
- build_zombie_surface,
84
+ build_survivor_directional_surfaces,
85
+ build_zombie_directional_surfaces,
86
+ draw_humanoid_hand,
87
+ draw_humanoid_nose,
72
88
  paint_car_surface,
73
89
  paint_steel_beam_surface,
74
90
  paint_wall_surface,
75
- paint_zombie_surface,
91
+ rubble_offset_for_size,
76
92
  resolve_car_color,
77
93
  resolve_steel_beam_colors,
78
94
  resolve_wall_colors,
95
+ RUBBLE_ROTATION_DEG,
79
96
  )
97
+ from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
80
98
  from .rng import get_rng
81
99
  from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
82
100
  from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
@@ -84,6 +102,31 @@ from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
84
102
  RNG = get_rng()
85
103
 
86
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
+
87
130
  class Wall(pygame.sprite.Sprite):
88
131
  def __init__(
89
132
  self: Self,
@@ -182,6 +225,67 @@ class Wall(pygame.sprite.Sprite):
182
225
  self._update_color()
183
226
 
184
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
+
185
289
  class SteelBeam(pygame.sprite.Sprite):
186
290
  """Single-cell obstacle that behaves like a tougher internal wall."""
187
291
 
@@ -229,11 +333,20 @@ class SteelBeam(pygame.sprite.Sprite):
229
333
  )
230
334
 
231
335
 
336
+ def _is_inner_wall(wall: pygame.sprite.Sprite) -> bool:
337
+ if isinstance(wall, SteelBeam):
338
+ return True
339
+ if isinstance(wall, Wall):
340
+ return wall.palette_category == "inner_wall"
341
+ return False
342
+
343
+
232
344
  MovementStrategy = Callable[
233
345
  [
234
346
  "Zombie",
235
347
  tuple[int, int],
236
348
  list[Wall],
349
+ Iterable["Zombie"],
237
350
  list[Footprint],
238
351
  int,
239
352
  int,
@@ -242,6 +355,8 @@ MovementStrategy = Callable[
242
355
  ],
243
356
  tuple[float, float],
244
357
  ]
358
+
359
+
245
360
  def _sprite_center_and_radius(
246
361
  sprite: pygame.sprite.Sprite,
247
362
  ) -> tuple[tuple[int, int], float]:
@@ -567,12 +682,22 @@ class Player(pygame.sprite.Sprite):
567
682
  ) -> None:
568
683
  super().__init__()
569
684
  self.radius = PLAYER_RADIUS
570
- self.image = build_player_surface(self.radius)
685
+ self.facing_bin = 0
686
+ self.input_facing_bin = 0
687
+ self.wall_bump_counter = 0
688
+ self.wall_bump_flip = 1
689
+ self.inner_wall_hit = False
690
+ self.inner_wall_cell = None
691
+ self.directional_images = build_player_directional_surfaces(self.radius)
692
+ self.image = self.directional_images[self.facing_bin]
571
693
  self.rect = self.image.get_rect(center=(x, y))
572
694
  self.speed = PLAYER_SPEED
573
695
  self.in_car = False
574
696
  self.x = float(self.rect.centerx)
575
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
576
701
 
577
702
  def move(
578
703
  self: Self,
@@ -584,6 +709,8 @@ class Player(pygame.sprite.Sprite):
584
709
  cell_size: int | None = None,
585
710
  level_width: int | None = None,
586
711
  level_height: int | None = None,
712
+ pitfall_cells: set[tuple[int, int]] | None = None,
713
+ walkable_cells: list[tuple[int, int]] | None = None,
587
714
  ) -> None:
588
715
  if self.in_car:
589
716
  return
@@ -591,6 +718,31 @@ class Player(pygame.sprite.Sprite):
591
718
  if level_width is None or level_height is None:
592
719
  raise ValueError("level_width/level_height are required for movement")
593
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
+
743
+ inner_wall_hit = False
744
+ inner_wall_cell: tuple[int, int] | None = None
745
+
594
746
  if dx != 0:
595
747
  self.x += dx
596
748
  self.x = min(level_width, max(0, self.x))
@@ -601,11 +753,32 @@ class Player(pygame.sprite.Sprite):
601
753
  wall_index=wall_index,
602
754
  cell_size=cell_size,
603
755
  )
604
- if hit_list_x:
605
- damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_x))
606
- for wall in hit_list_x:
607
- if wall.alive():
608
- wall._take_damage(amount=damage)
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
+ )
609
782
  self.x -= dx * 1.5
610
783
  self.rect.centerx = int(self.x)
611
784
 
@@ -619,15 +792,83 @@ class Player(pygame.sprite.Sprite):
619
792
  wall_index=wall_index,
620
793
  cell_size=cell_size,
621
794
  )
622
- if hit_list_y:
623
- damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_y))
624
- for wall in hit_list_y:
625
- if wall.alive():
626
- wall._take_damage(amount=damage)
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
+ )
627
821
  self.y -= dy * 1.5
628
822
  self.rect.centery = int(self.y)
629
823
 
630
824
  self.rect.center = (int(self.x), int(self.y))
825
+ self.inner_wall_hit = inner_wall_hit
826
+ self.inner_wall_cell = inner_wall_cell
827
+ self._update_facing_for_bump(inner_wall_hit)
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
+
842
+ def update_facing_from_input(self: Self, dx: float, dy: float) -> None:
843
+ if self.in_car:
844
+ return
845
+ new_bin = angle_bin_from_vector(dx, dy)
846
+ if new_bin is None:
847
+ return
848
+ self.input_facing_bin = new_bin
849
+
850
+ def _update_facing_for_bump(self: Self, inner_wall_hit: bool) -> None:
851
+ if self.in_car:
852
+ return
853
+ if inner_wall_hit:
854
+ self.wall_bump_counter += 1
855
+ if self.wall_bump_counter % HUMANOID_WALL_BUMP_FRAMES == 0:
856
+ self.wall_bump_flip *= -1
857
+ bumped_bin = (self.input_facing_bin + self.wall_bump_flip) % ANGLE_BINS
858
+ self._set_facing_bin(bumped_bin)
859
+ return
860
+ if self.wall_bump_counter:
861
+ self.wall_bump_counter = 0
862
+ self.wall_bump_flip = 1
863
+ self._set_facing_bin(self.input_facing_bin)
864
+
865
+ def _set_facing_bin(self: Self, new_bin: int) -> None:
866
+ if new_bin == self.facing_bin:
867
+ return
868
+ center = self.rect.center
869
+ self.facing_bin = new_bin
870
+ self.image = self.directional_images[self.facing_bin]
871
+ self.rect = self.image.get_rect(center=center)
631
872
 
632
873
 
633
874
  class Survivor(pygame.sprite.Sprite):
@@ -643,15 +884,24 @@ class Survivor(pygame.sprite.Sprite):
643
884
  super().__init__()
644
885
  self.is_buddy = is_buddy
645
886
  self.radius = BUDDY_RADIUS if is_buddy else SURVIVOR_RADIUS
646
- self.image = build_survivor_surface(
887
+ self.facing_bin = 0
888
+ self.input_facing_bin = 0
889
+ self.wall_bump_counter = 0
890
+ self.wall_bump_flip = 1
891
+ self.directional_images = build_survivor_directional_surfaces(
647
892
  self.radius,
648
893
  is_buddy=is_buddy,
894
+ draw_hands=is_buddy,
649
895
  )
896
+ self.image = self.directional_images[self.facing_bin]
650
897
  self.rect = self.image.get_rect(center=(int(x), int(y)))
651
898
  self.x = float(self.rect.centerx)
652
899
  self.y = float(self.rect.centery)
653
900
  self.following = False
654
901
  self.rescued = False
902
+ self.jump_start_at = 0
903
+ self.jump_duration = JUMP_DURATION_MS
904
+ self.is_jumping = False
655
905
 
656
906
  def set_following(self: Self) -> None:
657
907
  if self.is_buddy and not self.rescued:
@@ -677,25 +927,48 @@ class Survivor(pygame.sprite.Sprite):
677
927
  wall_index: WallIndex | None = None,
678
928
  cell_size: int | None = None,
679
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,
680
932
  bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
681
933
  | None = None,
682
934
  grid_cols: int | None = None,
683
935
  grid_rows: int | None = None,
684
936
  level_width: int | None = None,
685
937
  level_height: int | None = None,
938
+ wall_target_cell: tuple[int, int] | None = None,
686
939
  ) -> None:
687
940
  if level_width is None or level_height is None:
688
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
+
689
954
  if self.is_buddy:
690
955
  if self.rescued or not self.following:
691
956
  self.rect.center = (int(self.x), int(self.y))
692
957
  return
693
958
 
694
- dx = player_pos[0] - self.x
695
- dy = player_pos[1] - self.y
959
+ target_pos = player_pos
960
+ if wall_target_cell is not None and cell_size is not None:
961
+ target_pos = (
962
+ wall_target_cell[0] * cell_size + cell_size // 2,
963
+ wall_target_cell[1] * cell_size + cell_size // 2,
964
+ )
965
+
966
+ dx = target_pos[0] - self.x
967
+ dy = target_pos[1] - self.y
696
968
  dist_sq = dx * dx + dy * dy
697
969
  if dist_sq <= 0:
698
970
  self.rect.center = (int(self.x), int(self.y))
971
+ self._update_facing_for_bump(False)
699
972
  return
700
973
 
701
974
  dist = math.sqrt(dist_sq)
@@ -720,32 +993,91 @@ class Survivor(pygame.sprite.Sprite):
720
993
  grid_rows=grid_rows,
721
994
  )
722
995
 
996
+ self._update_input_facing(move_x, move_y)
997
+ inner_wall_hit = False
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
+
723
1016
  if move_x:
724
1017
  self.x += move_x
725
1018
  self.rect.centerx = int(self.x)
726
- if spritecollideany_walls(
1019
+ hit_wall = spritecollideany_walls(
727
1020
  self,
728
1021
  walls,
729
1022
  wall_index=wall_index,
730
1023
  cell_size=cell_size,
731
- ):
1024
+ )
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
732
1044
  self.x -= move_x
733
1045
  self.rect.centerx = int(self.x)
1046
+
734
1047
  if move_y:
735
1048
  self.y += move_y
736
1049
  self.rect.centery = int(self.y)
737
- if spritecollideany_walls(
1050
+ hit_wall = spritecollideany_walls(
738
1051
  self,
739
1052
  walls,
740
1053
  wall_index=wall_index,
741
1054
  cell_size=cell_size,
742
- ):
1055
+ )
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
743
1075
  self.y -= move_y
744
1076
  self.rect.centery = int(self.y)
745
1077
 
746
1078
  overlap_radius = (self.radius + PLAYER_RADIUS) * 1.05
747
- dx_after = player_pos[0] - self.x
748
- dy_after = player_pos[1] - self.y
1079
+ dx_after = target_pos[0] - self.x
1080
+ dy_after = target_pos[1] - self.y
749
1081
  dist_after_sq = dx_after * dx_after + dy_after * dy_after
750
1082
  if 0 < dist_after_sq < overlap_radius * overlap_radius:
751
1083
  dist_after = math.sqrt(dist_after_sq)
@@ -757,6 +1089,7 @@ class Survivor(pygame.sprite.Sprite):
757
1089
  self.x = min(level_width, max(0, self.x))
758
1090
  self.y = min(level_height, max(0, self.y))
759
1091
  self.rect.center = (int(self.x), int(self.y))
1092
+ self._update_facing_for_bump(inner_wall_hit)
760
1093
  return
761
1094
 
762
1095
  dx = player_pos[0] - self.x
@@ -772,6 +1105,25 @@ class Survivor(pygame.sprite.Sprite):
772
1105
  move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
773
1106
  move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
774
1107
 
1108
+ self._update_input_facing(move_x, move_y)
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
+
775
1127
  if (
776
1128
  cell_size is not None
777
1129
  and wall_cells is not None
@@ -793,27 +1145,99 @@ class Survivor(pygame.sprite.Sprite):
793
1145
  if move_x:
794
1146
  self.x += move_x
795
1147
  self.rect.centerx = int(self.x)
796
- if spritecollideany_walls(
1148
+ hit_by_wall = spritecollideany_walls(
797
1149
  self,
798
1150
  walls,
799
1151
  wall_index=wall_index,
800
1152
  cell_size=cell_size,
801
- ):
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:
802
1168
  self.x -= move_x
803
1169
  self.rect.centerx = int(self.x)
804
1170
  if move_y:
805
1171
  self.y += move_y
806
1172
  self.rect.centery = int(self.y)
807
- if spritecollideany_walls(
1173
+ hit_by_wall = spritecollideany_walls(
808
1174
  self,
809
1175
  walls,
810
1176
  wall_index=wall_index,
811
1177
  cell_size=cell_size,
812
- ):
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:
813
1193
  self.y -= move_y
814
1194
  self.rect.centery = int(self.y)
815
1195
 
816
1196
  self.rect.center = (int(self.x), int(self.y))
1197
+ self._update_facing_for_bump(False)
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
+
1212
+ def _update_input_facing(self: Self, dx: float, dy: float) -> None:
1213
+ new_bin = angle_bin_from_vector(dx, dy)
1214
+ if new_bin is None:
1215
+ return
1216
+ self.input_facing_bin = new_bin
1217
+
1218
+ def _update_facing_for_bump(self: Self, inner_wall_hit: bool) -> None:
1219
+ if not self.is_buddy:
1220
+ self._set_facing_bin(self.input_facing_bin)
1221
+ return
1222
+ if inner_wall_hit:
1223
+ self.wall_bump_counter += 1
1224
+ if self.wall_bump_counter % HUMANOID_WALL_BUMP_FRAMES == 0:
1225
+ self.wall_bump_flip *= -1
1226
+ bumped_bin = (self.input_facing_bin + self.wall_bump_flip) % ANGLE_BINS
1227
+ self._set_facing_bin(bumped_bin)
1228
+ return
1229
+ if self.wall_bump_counter:
1230
+ self.wall_bump_counter = 0
1231
+ self.wall_bump_flip = 1
1232
+ self._set_facing_bin(self.input_facing_bin)
1233
+
1234
+ def _set_facing_bin(self: Self, new_bin: int) -> None:
1235
+ if new_bin == self.facing_bin:
1236
+ return
1237
+ center = self.rect.center
1238
+ self.facing_bin = new_bin
1239
+ self.image = self.directional_images[self.facing_bin]
1240
+ self.rect = self.image.get_rect(center=center)
817
1241
 
818
1242
 
819
1243
  def random_position_outside_building(
@@ -836,6 +1260,7 @@ def _zombie_tracker_movement(
836
1260
  zombie: Zombie,
837
1261
  player_center: tuple[int, int],
838
1262
  walls: list[Wall],
1263
+ nearby_zombies: Iterable[Zombie],
839
1264
  footprints: list[Footprint],
840
1265
  cell_size: int,
841
1266
  grid_cols: int,
@@ -844,6 +1269,22 @@ def _zombie_tracker_movement(
844
1269
  ) -> tuple[float, float]:
845
1270
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
846
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
+ )
847
1288
  _zombie_update_tracker_target(zombie, footprints, walls)
848
1289
  if zombie.tracker_target_pos is not None:
849
1290
  return _zombie_move_toward(zombie, zombie.tracker_target_pos)
@@ -862,6 +1303,7 @@ def _zombie_wander_movement(
862
1303
  zombie: Zombie,
863
1304
  _player_center: tuple[int, int],
864
1305
  walls: list[Wall],
1306
+ _nearby_zombies: Iterable[Zombie],
865
1307
  _footprints: list[Footprint],
866
1308
  cell_size: int,
867
1309
  grid_cols: int,
@@ -878,7 +1320,7 @@ def _zombie_wander_movement(
878
1320
  )
879
1321
 
880
1322
 
881
- def _zombie_wall_follow_has_wall(
1323
+ def _zombie_wall_hug_has_wall(
882
1324
  zombie: Zombie,
883
1325
  walls: list[Wall],
884
1326
  angle: float,
@@ -898,13 +1340,13 @@ def _zombie_wall_follow_has_wall(
898
1340
  )
899
1341
 
900
1342
 
901
- def _zombie_wall_follow_wall_distance(
1343
+ def _zombie_wall_hug_wall_distance(
902
1344
  zombie: Zombie,
903
1345
  walls: list[Wall],
904
1346
  angle: float,
905
1347
  max_distance: float,
906
1348
  *,
907
- step: float = ZOMBIE_WALL_FOLLOW_PROBE_STEP,
1349
+ step: float = ZOMBIE_WALL_HUG_PROBE_STEP,
908
1350
  ) -> float:
909
1351
  direction_x = math.cos(angle)
910
1352
  direction_y = math.sin(angle)
@@ -930,10 +1372,11 @@ def _zombie_wall_follow_wall_distance(
930
1372
  return max_distance
931
1373
 
932
1374
 
933
- def _zombie_wall_follow_movement(
1375
+ def _zombie_wall_hug_movement(
934
1376
  zombie: Zombie,
935
1377
  player_center: tuple[int, int],
936
1378
  walls: list[Wall],
1379
+ _nearby_zombies: Iterable[Zombie],
937
1380
  _footprints: list[Footprint],
938
1381
  cell_size: int,
939
1382
  grid_cols: int,
@@ -941,21 +1384,21 @@ def _zombie_wall_follow_movement(
941
1384
  outer_wall_cells: set[tuple[int, int]] | None,
942
1385
  ) -> tuple[float, float]:
943
1386
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
944
- if zombie.wall_follow_angle is None:
945
- zombie.wall_follow_angle = zombie.wander_angle
946
- if zombie.wall_follow_side == 0:
947
- sensor_distance = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius
948
- forward_angle = zombie.wall_follow_angle
949
- 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)
950
1393
  left_angle = forward_angle + probe_offset
951
1394
  right_angle = forward_angle - probe_offset
952
- left_dist = _zombie_wall_follow_wall_distance(
1395
+ left_dist = _zombie_wall_hug_wall_distance(
953
1396
  zombie, walls, left_angle, sensor_distance
954
1397
  )
955
- right_dist = _zombie_wall_follow_wall_distance(
1398
+ right_dist = _zombie_wall_hug_wall_distance(
956
1399
  zombie, walls, right_angle, sensor_distance
957
1400
  )
958
- forward_dist = _zombie_wall_follow_wall_distance(
1401
+ forward_dist = _zombie_wall_hug_wall_distance(
959
1402
  zombie, walls, forward_angle, sensor_distance
960
1403
  )
961
1404
  left_wall = left_dist < sensor_distance
@@ -963,15 +1406,15 @@ def _zombie_wall_follow_movement(
963
1406
  forward_wall = forward_dist < sensor_distance
964
1407
  if left_wall or right_wall or forward_wall:
965
1408
  if left_wall and not right_wall:
966
- zombie.wall_follow_side = 1.0
1409
+ zombie.wall_hug_side = 1.0
967
1410
  elif right_wall and not left_wall:
968
- zombie.wall_follow_side = -1.0
1411
+ zombie.wall_hug_side = -1.0
969
1412
  elif left_wall and right_wall:
970
- 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
971
1414
  else:
972
- zombie.wall_follow_side = RNG.choice([-1.0, 1.0])
973
- zombie.wall_follow_last_wall_time = pygame.time.get_ticks()
974
- 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
975
1418
  else:
976
1419
  if is_in_sight:
977
1420
  return _zombie_move_toward(zombie, player_center)
@@ -984,53 +1427,53 @@ def _zombie_wall_follow_movement(
984
1427
  outer_wall_cells=outer_wall_cells,
985
1428
  )
986
1429
 
987
- sensor_distance = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius
988
- probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
989
- side_angle = zombie.wall_follow_angle + zombie.wall_follow_side * probe_offset
990
- 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(
991
1434
  zombie, walls, side_angle, sensor_distance
992
1435
  )
993
- forward_dist = _zombie_wall_follow_wall_distance(
994
- 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
995
1438
  )
996
1439
  side_has_wall = side_dist < sensor_distance
997
1440
  forward_has_wall = forward_dist < sensor_distance
998
1441
  now = pygame.time.get_ticks()
999
1442
  wall_recent = (
1000
- zombie.wall_follow_last_wall_time is not None
1001
- 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
1002
1445
  )
1003
1446
  if is_in_sight:
1004
1447
  return _zombie_move_toward(zombie, player_center)
1005
1448
 
1006
1449
  turn_step = math.radians(5)
1007
1450
  if side_has_wall or forward_has_wall:
1008
- zombie.wall_follow_last_wall_time = now
1451
+ zombie.wall_hug_last_wall_time = now
1009
1452
  if side_has_wall:
1010
- zombie.wall_follow_last_side_has_wall = True
1011
- 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
1012
1455
  if abs(gap_error) > 0.1:
1013
- 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)
1014
1457
  turn = turn_step * ratio
1015
1458
  if gap_error > 0:
1016
- zombie.wall_follow_angle -= zombie.wall_follow_side * turn
1459
+ zombie.wall_hug_angle -= zombie.wall_hug_side * turn
1017
1460
  else:
1018
- zombie.wall_follow_angle += zombie.wall_follow_side * turn
1019
- if forward_dist < ZOMBIE_WALL_FOLLOW_TARGET_GAP:
1020
- 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)
1021
1464
  else:
1022
- zombie.wall_follow_last_side_has_wall = False
1465
+ zombie.wall_hug_last_side_has_wall = False
1023
1466
  if forward_has_wall:
1024
- zombie.wall_follow_angle -= zombie.wall_follow_side * turn_step
1467
+ zombie.wall_hug_angle -= zombie.wall_hug_side * turn_step
1025
1468
  elif wall_recent:
1026
- 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)
1027
1470
  else:
1028
- zombie.wall_follow_angle += zombie.wall_follow_side * (math.pi / 2.0)
1029
- zombie.wall_follow_side = 0.0
1030
- 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
1031
1474
 
1032
- move_x = math.cos(zombie.wall_follow_angle) * zombie.speed
1033
- 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
1034
1477
  return move_x, move_y
1035
1478
 
1036
1479
 
@@ -1038,6 +1481,7 @@ def _zombie_normal_movement(
1038
1481
  zombie: Zombie,
1039
1482
  player_center: tuple[int, int],
1040
1483
  walls: list[Wall],
1484
+ _nearby_zombies: Iterable[Zombie],
1041
1485
  _footprints: list[Footprint],
1042
1486
  cell_size: int,
1043
1487
  grid_cols: int,
@@ -1071,12 +1515,29 @@ def _zombie_update_tracker_target(
1071
1515
  return
1072
1516
  nearby: list[Footprint] = []
1073
1517
  last_target_time = zombie.tracker_target_time
1074
- 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
+ )
1075
1534
  scent_radius_sq = scan_radius * scan_radius
1076
1535
  min_target_dist_sq = (FOOTPRINT_STEP_DISTANCE * 0.5) ** 2
1077
1536
  for fp in footprints:
1078
1537
  pos = fp.pos
1079
1538
  fp_time = fp.time
1539
+ if relock_after is not None and fp_time < relock_after:
1540
+ continue
1080
1541
  dx = pos[0] - zombie.x
1081
1542
  dy = pos[1] - zombie.y
1082
1543
  if dx * dx + dy * dy <= min_target_dist_sq:
@@ -1099,6 +1560,8 @@ def _zombie_update_tracker_target(
1099
1560
  if _line_of_sight_clear((zombie.x, zombie.y), pos, walls):
1100
1561
  zombie.tracker_target_pos = pos
1101
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
1102
1565
  return
1103
1566
 
1104
1567
  if (
@@ -1119,9 +1582,45 @@ def _zombie_update_tracker_target(
1119
1582
  next_fp = candidates[0]
1120
1583
  zombie.tracker_target_pos = next_fp.pos
1121
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
1122
1587
  return
1123
1588
 
1124
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
+
1125
1624
  def _zombie_wander_move(
1126
1625
  zombie: Zombie,
1127
1626
  walls: list[Wall],
@@ -1218,19 +1717,21 @@ class Zombie(pygame.sprite.Sprite):
1218
1717
  *,
1219
1718
  speed: float = ZOMBIE_SPEED,
1220
1719
  tracker: bool = False,
1221
- wall_follower: bool = False,
1720
+ wall_hugging: bool = False,
1222
1721
  movement_strategy: MovementStrategy | None = None,
1223
1722
  aging_duration_frames: float = ZOMBIE_AGING_DURATION_FRAMES,
1224
1723
  ) -> None:
1225
1724
  super().__init__()
1226
1725
  self.radius = ZOMBIE_RADIUS
1726
+ self.facing_bin = 0
1227
1727
  self.tracker = tracker
1228
- self.wall_follower = wall_follower
1728
+ self.wall_hugging = wall_hugging
1229
1729
  self.carbonized = False
1230
- self.image = build_zombie_surface(
1231
- self.radius, tracker=self.tracker, wall_follower=self.wall_follower
1730
+ self.directional_images = build_zombie_directional_surfaces(
1731
+ self.radius,
1732
+ draw_hands=False,
1232
1733
  )
1233
- self._redraw_image()
1734
+ self.image = self.directional_images[self.facing_bin]
1234
1735
  self.rect = self.image.get_rect(center=(x, y))
1235
1736
  jitter_base = FAST_ZOMBIE_BASE_SPEED if speed > ZOMBIE_SPEED else ZOMBIE_SPEED
1236
1737
  jitter = jitter_base * 0.2
@@ -1245,8 +1746,8 @@ class Zombie(pygame.sprite.Sprite):
1245
1746
  if movement_strategy is None:
1246
1747
  if tracker:
1247
1748
  movement_strategy = _zombie_tracker_movement
1248
- elif wall_follower:
1249
- movement_strategy = _zombie_wall_follow_movement
1749
+ elif wall_hugging:
1750
+ movement_strategy = _zombie_wall_hug_movement
1250
1751
  else:
1251
1752
  movement_strategy = _zombie_normal_movement
1252
1753
  self.movement_strategy = movement_strategy
@@ -1254,11 +1755,12 @@ class Zombie(pygame.sprite.Sprite):
1254
1755
  self.tracker_target_time: int | None = None
1255
1756
  self.tracker_last_scan_time = 0
1256
1757
  self.tracker_scan_interval_ms = ZOMBIE_TRACKER_SCAN_INTERVAL_MS
1257
- self.wall_follow_side = RNG.choice([-1.0, 1.0]) if wall_follower else 0.0
1258
- self.wall_follow_angle = RNG.uniform(0, math.tau) if wall_follower else None
1259
- self.wall_follow_last_wall_time: int | None = None
1260
- self.wall_follow_last_side_has_wall = False
1261
- 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
1262
1764
  self.pos_history: list[tuple[float, float]] = []
1263
1765
  self.wander_angle = RNG.uniform(0, math.tau)
1264
1766
  self.wander_interval_ms = (
@@ -1268,15 +1770,8 @@ class Zombie(pygame.sprite.Sprite):
1268
1770
  self.wander_change_interval = max(
1269
1771
  0, self.wander_interval_ms + RNG.randint(-500, 500)
1270
1772
  )
1271
-
1272
- def _redraw_image(self: Self, palm_angle: float | None = None) -> None:
1273
- paint_zombie_surface(
1274
- self.image,
1275
- radius=self.radius,
1276
- palm_angle=palm_angle,
1277
- tracker=self.tracker,
1278
- wall_follower=self.wall_follower,
1279
- )
1773
+ self.last_move_dx = 0.0
1774
+ self.last_move_dy = 0.0
1280
1775
 
1281
1776
  def _update_mode(
1282
1777
  self: Self, player_center: tuple[int, int], sight_range: float
@@ -1349,17 +1844,17 @@ class Zombie(pygame.sprite.Sprite):
1349
1844
  if closest is None:
1350
1845
  return move_x, move_y
1351
1846
 
1352
- if self.wall_follower:
1847
+ if self.wall_hugging:
1353
1848
  other_radius = float(closest.radius)
1354
1849
  bump_dist_sq = (self.radius + other_radius) ** 2
1355
1850
  if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
1356
- if self.wall_follow_angle is None:
1357
- self.wall_follow_angle = self.wander_angle
1358
- self.wall_follow_angle = (self.wall_follow_angle + math.pi) % math.tau
1359
- 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
1360
1855
  return (
1361
- math.cos(self.wall_follow_angle) * self.speed,
1362
- 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,
1363
1858
  )
1364
1859
 
1365
1860
  away_dx = next_x - closest.x
@@ -1384,6 +1879,53 @@ class Zombie(pygame.sprite.Sprite):
1384
1879
  slowdown_ratio = 1.0 - progress * (1.0 - ZOMBIE_AGING_MIN_SPEED_RATIO)
1385
1880
  self.speed = self.initial_speed * slowdown_ratio
1386
1881
 
1882
+ def _set_facing_bin(self: Self, new_bin: int) -> None:
1883
+ if new_bin == self.facing_bin:
1884
+ return
1885
+ center = self.rect.center
1886
+ self.facing_bin = new_bin
1887
+ self.image = self.directional_images[self.facing_bin]
1888
+ self.rect = self.image.get_rect(center=center)
1889
+
1890
+ def _update_facing_from_movement(self: Self, dx: float, dy: float) -> None:
1891
+ new_bin = angle_bin_from_vector(dx, dy)
1892
+ if new_bin is None:
1893
+ return
1894
+ self._set_facing_bin(new_bin)
1895
+
1896
+ def _apply_render_overlays(self: Self) -> None:
1897
+ base_surface = self.directional_images[self.facing_bin]
1898
+ needs_overlay = self.tracker or (
1899
+ self.wall_hugging
1900
+ and self.wall_hug_side != 0
1901
+ and self.wall_hug_last_side_has_wall
1902
+ )
1903
+ if not needs_overlay:
1904
+ self.image = base_surface
1905
+ return
1906
+ self.image = base_surface.copy()
1907
+ angle_rad = (self.facing_bin % ANGLE_BINS) * (math.tau / ANGLE_BINS)
1908
+ if self.tracker:
1909
+ draw_humanoid_nose(
1910
+ self.image,
1911
+ radius=self.radius,
1912
+ angle_rad=angle_rad,
1913
+ color=ZOMBIE_NOSE_COLOR,
1914
+ )
1915
+ if (
1916
+ self.wall_hugging
1917
+ and self.wall_hug_side != 0
1918
+ and self.wall_hug_last_side_has_wall
1919
+ ):
1920
+ side_sign = 1.0 if self.wall_hug_side > 0 else -1.0
1921
+ hand_angle = angle_rad + side_sign * (math.pi / 2.0)
1922
+ draw_humanoid_hand(
1923
+ self.image,
1924
+ radius=self.radius,
1925
+ angle_rad=hand_angle,
1926
+ color=ZOMBIE_NOSE_COLOR,
1927
+ )
1928
+
1387
1929
  def _update_stuck_state(self: Self) -> None:
1388
1930
  history = self.pos_history
1389
1931
  history.append((self.x, self.y))
@@ -1392,17 +1934,50 @@ class Zombie(pygame.sprite.Sprite):
1392
1934
  max_dist_sq = max(
1393
1935
  (self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history
1394
1936
  )
1395
- self.wall_follow_stuck_flag = max_dist_sq < 25
1396
- 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:
1397
1939
  return
1398
- if self.wall_follower:
1399
- if self.wall_follow_angle is None:
1400
- self.wall_follow_angle = self.wander_angle
1401
- self.wall_follow_angle = (self.wall_follow_angle + math.pi) % math.tau
1402
- self.wall_follow_side *= -1.0
1403
- 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
1404
1946
  self.pos_history = []
1405
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
+
1406
1981
  def update(
1407
1982
  self: Self,
1408
1983
  player_center: tuple[int, int],
@@ -1417,6 +1992,7 @@ class Zombie(pygame.sprite.Sprite):
1417
1992
  level_height: int,
1418
1993
  outer_wall_cells: set[tuple[int, int]] | None = None,
1419
1994
  wall_cells: set[tuple[int, int]] | None = None,
1995
+ pitfall_cells: set[tuple[int, int]] | None = None,
1420
1996
  bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
1421
1997
  | None = None,
1422
1998
  ) -> None:
@@ -1433,13 +2009,18 @@ class Zombie(pygame.sprite.Sprite):
1433
2009
  self,
1434
2010
  player_center,
1435
2011
  walls,
2012
+ nearby_zombies,
1436
2013
  footprints or [],
1437
2014
  cell_size,
1438
2015
  grid_cols,
1439
2016
  grid_rows,
1440
2017
  outer_wall_cells,
1441
2018
  )
1442
- 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:
1443
2024
  move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
1444
2025
  if wall_cells is not None:
1445
2026
  move_x, move_y = apply_tile_edge_nudge(
@@ -1453,18 +2034,10 @@ class Zombie(pygame.sprite.Sprite):
1453
2034
  grid_cols=grid_cols,
1454
2035
  grid_rows=grid_rows,
1455
2036
  )
1456
- if self.wall_follower and self.wall_follow_side != 0:
1457
- if move_x != 0 or move_y != 0:
1458
- heading = math.atan2(move_y, move_x)
1459
- elif self.wall_follow_angle is not None:
1460
- heading = self.wall_follow_angle
1461
- else:
1462
- heading = self.wander_angle
1463
- if self.wall_follow_side > 0:
1464
- palm_angle = heading + (math.pi / 2.0)
1465
- else:
1466
- palm_angle = heading - (math.pi / 2.0)
1467
- self._redraw_image(palm_angle)
2037
+ self._update_facing_from_movement(move_x, move_y)
2038
+ self._apply_render_overlays()
2039
+ self.last_move_dx = move_x
2040
+ self.last_move_dy = move_y
1468
2041
  final_x, final_y = self._handle_wall_collision(
1469
2042
  self.x + move_x, self.y + move_y, walls
1470
2043
  )
@@ -1482,23 +2055,31 @@ class Zombie(pygame.sprite.Sprite):
1482
2055
  return
1483
2056
  self.carbonized = True
1484
2057
  self.speed = 0
2058
+ self.image = self.directional_images[self.facing_bin].copy()
1485
2059
  self.image.fill((0, 0, 0, 0))
1486
2060
  color = (80, 80, 80)
1487
- pygame.draw.circle(self.image, color, (self.radius, self.radius), self.radius)
2061
+ center = self.image.get_rect().center
2062
+ pygame.draw.circle(self.image, color, center, self.radius)
1488
2063
  pygame.draw.circle(
1489
- self.image, (30, 30, 30), (self.radius, self.radius), self.radius, width=1
2064
+ self.image,
2065
+ (30, 30, 30),
2066
+ center,
2067
+ self.radius,
2068
+ width=1,
1490
2069
  )
1491
2070
 
1492
2071
 
1493
2072
  class Car(pygame.sprite.Sprite):
1494
2073
  def __init__(self: Self, x: int, y: int, *, appearance: str = "default") -> None:
1495
2074
  super().__init__()
2075
+ self.facing_bin = ANGLE_BINS * 3 // 4
2076
+ self.input_facing_bin = self.facing_bin
1496
2077
  self.original_image = build_car_surface(CAR_WIDTH, CAR_HEIGHT)
2078
+ self.directional_images: list[pygame.Surface] = []
1497
2079
  self.appearance = appearance
1498
2080
  self.image = self.original_image.copy()
1499
2081
  self.rect = self.image.get_rect(center=(x, y))
1500
2082
  self.speed = CAR_SPEED
1501
- self.angle = 0
1502
2083
  self.x = float(self.rect.centerx)
1503
2084
  self.y = float(self.rect.centery)
1504
2085
  self.health = CAR_HEALTH
@@ -1520,10 +2101,28 @@ class Car(pygame.sprite.Sprite):
1520
2101
  height=CAR_HEIGHT,
1521
2102
  color=color,
1522
2103
  )
1523
- self.image = pygame.transform.rotate(self.original_image, self.angle)
2104
+ self.directional_images = build_car_directional_surfaces(self.original_image)
2105
+ self.image = self.directional_images[self.facing_bin]
1524
2106
  old_center = self.rect.center
1525
2107
  self.rect = self.image.get_rect(center=old_center)
1526
2108
 
2109
+ def update_facing_from_input(self: Self, dx: float, dy: float) -> None:
2110
+ new_bin = angle_bin_from_vector(dx, dy)
2111
+ if new_bin is None:
2112
+ return
2113
+ self.input_facing_bin = new_bin
2114
+ self._set_facing_bin(self.input_facing_bin)
2115
+
2116
+ def _set_facing_bin(self: Self, new_bin: int) -> None:
2117
+ if new_bin == self.facing_bin:
2118
+ return
2119
+ if not self.directional_images:
2120
+ return
2121
+ center = self.rect.center
2122
+ self.facing_bin = new_bin
2123
+ self.image = self.directional_images[self.facing_bin]
2124
+ self.rect = self.image.get_rect(center=center)
2125
+
1527
2126
  def move(
1528
2127
  self: Self,
1529
2128
  dx: float,
@@ -1531,17 +2130,14 @@ class Car(pygame.sprite.Sprite):
1531
2130
  walls: Iterable[Wall],
1532
2131
  *,
1533
2132
  walls_nearby: bool = False,
2133
+ cell_size: int | None = None,
2134
+ pitfall_cells: set[tuple[int, int]] | None = None,
1534
2135
  ) -> None:
1535
2136
  if self.health <= 0:
1536
2137
  return
1537
2138
  if dx == 0 and dy == 0:
1538
2139
  self.rect.center = (int(self.x), int(self.y))
1539
2140
  return
1540
- target_angle = math.degrees(math.atan2(-dy, dx)) - 90
1541
- self.angle = target_angle
1542
- self.image = pygame.transform.rotate(self.original_image, self.angle)
1543
- old_center = (self.x, self.y)
1544
- self.rect = self.image.get_rect(center=old_center)
1545
2141
  new_x = self.x + dx
1546
2142
  new_y = self.y + dy
1547
2143
 
@@ -1559,15 +2155,27 @@ class Car(pygame.sprite.Sprite):
1559
2155
  for wall in possible_walls:
1560
2156
  if _circle_wall_collision(car_center, self.collision_radius, wall):
1561
2157
  hit_walls.append(wall)
1562
- if hit_walls:
1563
- self._take_damage(CAR_WALL_DAMAGE)
1564
- hit_walls.sort(
1565
- key=lambda w: (w.rect.centery - self.y) ** 2
1566
- + (w.rect.centerx - self.x) ** 2
1567
- )
1568
- nearest_wall = hit_walls[0]
1569
- new_x += (self.x - nearest_wall.rect.centerx) * 1.2
1570
- 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
1571
2179
 
1572
2180
  self.x = new_x
1573
2181
  self.y = new_y
@@ -1608,6 +2216,7 @@ def _car_body_radius(width: float, height: float) -> float:
1608
2216
 
1609
2217
  __all__ = [
1610
2218
  "Wall",
2219
+ "RubbleWall",
1611
2220
  "SteelBeam",
1612
2221
  "Camera",
1613
2222
  "Player",