zombie-escape 1.5.0__tar.gz → 1.5.4__tar.gz

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 (36) hide show
  1. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/PKG-INFO +2 -6
  2. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/README.md +1 -5
  3. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/__about__.py +1 -1
  4. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/colors.py +4 -0
  5. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/entities.py +223 -102
  6. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/gameplay/logic.py +240 -131
  7. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/gameplay_constants.py +13 -7
  8. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/locales/ui.en.json +5 -2
  9. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/locales/ui.ja.json +7 -4
  10. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/models.py +10 -5
  11. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/render.py +37 -86
  12. zombie_escape-1.5.4/src/zombie_escape/screens/__init__.py +222 -0
  13. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/screens/game_over.py +21 -8
  14. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/screens/gameplay.py +25 -13
  15. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/screens/settings.py +12 -1
  16. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/screens/title.py +34 -2
  17. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/stage_constants.py +5 -3
  18. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/zombie_escape.py +46 -11
  19. zombie_escape-1.5.0/src/zombie_escape/screens/__init__.py +0 -109
  20. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/.gitignore +0 -0
  21. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/LICENSE.txt +0 -0
  22. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/pyproject.toml +0 -0
  23. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/__init__.py +0 -0
  24. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/assets/fonts/Silkscreen-Regular.ttf +0 -0
  25. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/assets/fonts/misaki_gothic.ttf +0 -0
  26. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/config.py +0 -0
  27. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/font_utils.py +0 -0
  28. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/gameplay/__init__.py +0 -0
  29. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/level_blueprints.py +0 -0
  30. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/level_constants.py +0 -0
  31. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/localization.py +0 -0
  32. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/progress.py +0 -0
  33. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/render_assets.py +0 -0
  34. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/render_constants.py +0 -0
  35. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/rng.py +0 -0
  36. {zombie_escape-1.5.0 → zombie_escape-1.5.4}/src/zombie_escape/screen_constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zombie-escape
3
- Version: 1.5.0
3
+ Version: 1.5.4
4
4
  Summary: Top-down zombie survival game built with pygame.
5
5
  Project-URL: Homepage, https://github.com/tos-kamiya/zombie-escape
6
6
  Author-email: Toshihiro Kamiya <kamiya@mbj.nifty.com>
@@ -48,7 +48,7 @@ This game is a simple 2D top-down action game where the player aims to escape by
48
48
  - **Enter Car:** Overlap the player with the car.
49
49
  - **Quit Game:** `ESC` key
50
50
  - **Restart:** `R` key (on Game Over/Clear screen)
51
- - **Window Scale (title/settings only):** `[` to shrink, `]` to enlarge
51
+ - **Window/Fullscreen (title/settings only):** `[` to shrink, `]` to enlarge, `F` to toggle fullscreen
52
52
  - **Time Acceleration:** Hold either `Shift` key to run the entire world 4x faster; release to return to normal speed.
53
53
 
54
54
  ## Title Screen
@@ -142,10 +142,6 @@ Launch using the following command line:
142
142
  zombie-escape
143
143
  ```
144
144
 
145
- ### Debug Mode
146
-
147
- <!-- For Stage 5 balancing or capture needs, run `zombie-escape --debug`. This hides the pause overlay and starts survival stages with 3 minutes remaining so you can immediately test late-night behavior. -->
148
-
149
145
  ## License
150
146
 
151
147
  This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details.
@@ -26,7 +26,7 @@ This game is a simple 2D top-down action game where the player aims to escape by
26
26
  - **Enter Car:** Overlap the player with the car.
27
27
  - **Quit Game:** `ESC` key
28
28
  - **Restart:** `R` key (on Game Over/Clear screen)
29
- - **Window Scale (title/settings only):** `[` to shrink, `]` to enlarge
29
+ - **Window/Fullscreen (title/settings only):** `[` to shrink, `]` to enlarge, `F` to toggle fullscreen
30
30
  - **Time Acceleration:** Hold either `Shift` key to run the entire world 4x faster; release to return to normal speed.
31
31
 
32
32
  ## Title Screen
@@ -120,10 +120,6 @@ Launch using the following command line:
120
120
  zombie-escape
121
121
  ```
122
122
 
123
- ### Debug Mode
124
-
125
- <!-- For Stage 5 balancing or capture needs, run `zombie-escape --debug`. This hides the pause overlay and starts survival stages with 3 minutes remaining so you can immediately test late-night behavior. -->
126
-
127
123
  ## License
128
124
 
129
125
  This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details.
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "1.5.0"
4
+ __version__ = "1.5.4"
@@ -13,6 +13,8 @@ LIGHT_GRAY: tuple[int, int, int] = (200, 200, 200)
13
13
  YELLOW: tuple[int, int, int] = (255, 255, 0)
14
14
  ORANGE: tuple[int, int, int] = (255, 165, 0)
15
15
  DARK_RED: tuple[int, int, int] = (139, 0, 0)
16
+ TRACKER_OUTLINE_COLOR: tuple[int, int, int] = (170, 70, 220)
17
+ WALL_FOLLOWER_OUTLINE_COLOR: tuple[int, int, int] = (140, 140, 140)
16
18
 
17
19
 
18
20
  @dataclass(frozen=True)
@@ -146,6 +148,8 @@ __all__ = [
146
148
  "YELLOW",
147
149
  "ORANGE",
148
150
  "DARK_RED",
151
+ "TRACKER_OUTLINE_COLOR",
152
+ "WALL_FOLLOWER_OUTLINE_COLOR",
149
153
  "DAWN_AMBIENT_PALETTE_KEY",
150
154
  "INTERNAL_WALL_COLOR",
151
155
  "INTERNAL_WALL_BORDER_COLOR",
@@ -18,6 +18,8 @@ from .colors import (
18
18
  RED,
19
19
  STEEL_BEAM_COLOR,
20
20
  STEEL_BEAM_LINE_COLOR,
21
+ TRACKER_OUTLINE_COLOR,
22
+ WALL_FOLLOWER_OUTLINE_COLOR,
21
23
  YELLOW,
22
24
  )
23
25
  from .gameplay_constants import (
@@ -26,9 +28,9 @@ from .gameplay_constants import (
26
28
  CAR_SPEED,
27
29
  CAR_WALL_DAMAGE,
28
30
  CAR_WIDTH,
29
- COMPANION_COLOR,
30
- COMPANION_FOLLOW_SPEED,
31
- COMPANION_RADIUS,
31
+ BUDDY_COLOR,
32
+ BUDDY_FOLLOW_SPEED,
33
+ BUDDY_RADIUS,
32
34
  FAST_ZOMBIE_BASE_SPEED,
33
35
  FLASHLIGHT_HEIGHT,
34
36
  FLASHLIGHT_WIDTH,
@@ -57,7 +59,10 @@ from .gameplay_constants import (
57
59
  ZOMBIE_TRACKER_WANDER_INTERVAL_MS,
58
60
  ZOMBIE_WALL_DAMAGE,
59
61
  ZOMBIE_WALL_FOLLOW_LOST_WALL_MS,
62
+ ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG,
63
+ ZOMBIE_WALL_FOLLOW_PROBE_STEP,
60
64
  ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
65
+ ZOMBIE_WALL_FOLLOW_TARGET_GAP,
61
66
  ZOMBIE_WANDER_INTERVAL_MS,
62
67
  car_body_radius,
63
68
  )
@@ -71,6 +76,50 @@ MovementStrategy = Callable[
71
76
  ["Zombie", tuple[int, int], list["Wall"], list[dict[str, object]]],
72
77
  tuple[float, float],
73
78
  ]
79
+ WallIndex = dict[tuple[int, int], list["Wall"]]
80
+
81
+
82
+ def build_wall_index(walls: Iterable["Wall"]) -> WallIndex:
83
+ index: WallIndex = {}
84
+ for wall in walls:
85
+ if not wall.alive():
86
+ continue
87
+ cell_x = int(wall.rect.centerx // CELL_SIZE)
88
+ cell_y = int(wall.rect.centery // CELL_SIZE)
89
+ index.setdefault((cell_x, cell_y), []).append(wall)
90
+ return index
91
+
92
+
93
+ def _sprite_center_and_radius(
94
+ sprite: pygame.sprite.Sprite,
95
+ ) -> tuple[tuple[int, int], float]:
96
+ center = sprite.rect.center
97
+ radius = float(
98
+ getattr(sprite, "radius", max(sprite.rect.width, sprite.rect.height) / 2)
99
+ )
100
+ return center, radius
101
+
102
+
103
+ def walls_for_radius(
104
+ wall_index: WallIndex, center: tuple[float, float], radius: float
105
+ ) -> list["Wall"]:
106
+ search_radius = radius + CELL_SIZE
107
+ min_x = max(0, int((center[0] - search_radius) // CELL_SIZE))
108
+ max_x = min(GRID_COLS - 1, int((center[0] + search_radius) // CELL_SIZE))
109
+ min_y = max(0, int((center[1] - search_radius) // CELL_SIZE))
110
+ max_y = min(GRID_ROWS - 1, int((center[1] + search_radius) // CELL_SIZE))
111
+ candidates: list[Wall] = []
112
+ for cy in range(min_y, max_y + 1):
113
+ for cx in range(min_x, max_x + 1):
114
+ candidates.extend(wall_index.get((cx, cy), []))
115
+ return candidates
116
+
117
+
118
+ def _walls_for_sprite(
119
+ sprite: pygame.sprite.Sprite, wall_index: WallIndex
120
+ ) -> list["Wall"]:
121
+ center, radius = _sprite_center_and_radius(sprite)
122
+ return walls_for_radius(wall_index, center, radius)
74
123
 
75
124
 
76
125
  def circle_rect_collision(
@@ -108,7 +157,7 @@ def _build_beveled_polygon(
108
157
  if d == 0 or not any(bevels):
109
158
  return [(0, 0), (width, 0), (width, height), (0, height)]
110
159
 
111
- segments = max(4, d // 2)
160
+ segments = 4
112
161
  tl, tr, br, bl = bevels
113
162
  points: list[tuple[int, int]] = []
114
163
 
@@ -311,17 +360,36 @@ def spritecollide_walls(
311
360
  walls: pygame.sprite.Group,
312
361
  *,
313
362
  dokill: bool = False,
363
+ wall_index: WallIndex | None = None,
314
364
  ) -> list[pygame.sprite.Sprite]:
315
- return pygame.sprite.spritecollide(
316
- sprite, walls, dokill, collided=collide_sprite_wall
317
- )
365
+ if wall_index is None:
366
+ return pygame.sprite.spritecollide(
367
+ sprite, walls, dokill, collided=collide_sprite_wall
368
+ )
369
+ candidates = _walls_for_sprite(sprite, wall_index)
370
+ if not candidates:
371
+ return []
372
+ hit_list = [wall for wall in candidates if collide_sprite_wall(sprite, wall)]
373
+ if dokill:
374
+ for wall in hit_list:
375
+ wall.kill()
376
+ return hit_list
318
377
 
319
378
 
320
379
  def spritecollideany_walls(
321
380
  sprite: pygame.sprite.Sprite,
322
381
  walls: pygame.sprite.Group,
382
+ *,
383
+ wall_index: WallIndex | None = None,
323
384
  ) -> pygame.sprite.Sprite | None:
324
- return pygame.sprite.spritecollideany(sprite, walls, collided=collide_sprite_wall)
385
+ if wall_index is None:
386
+ return pygame.sprite.spritecollideany(
387
+ sprite, walls, collided=collide_sprite_wall
388
+ )
389
+ for wall in _walls_for_sprite(sprite, wall_index):
390
+ if collide_sprite_wall(sprite, wall):
391
+ return wall
392
+ return None
325
393
 
326
394
 
327
395
  def circle_wall_collision(
@@ -490,8 +558,10 @@ class Wall(pygame.sprite.Sprite):
490
558
  return rect_polygon_collision(rect_obj, self._collision_polygon)
491
559
 
492
560
  def collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
561
+ if not circle_rect_collision(center, radius, self.rect):
562
+ return False
493
563
  if self._collision_polygon is None:
494
- return circle_rect_collision(center, radius, self.rect)
564
+ return True
495
565
  return circle_polygon_collision(center, radius, self._collision_polygon)
496
566
 
497
567
  def set_palette_colors(
@@ -621,7 +691,14 @@ class Player(pygame.sprite.Sprite):
621
691
  self.x = float(self.rect.centerx)
622
692
  self.y = float(self.rect.centery)
623
693
 
624
- def move(self: Self, dx: float, dy: float, walls: pygame.sprite.Group) -> None:
694
+ def move(
695
+ self: Self,
696
+ dx: float,
697
+ dy: float,
698
+ walls: pygame.sprite.Group,
699
+ *,
700
+ wall_index: WallIndex | None = None,
701
+ ) -> None:
625
702
  if self.in_car:
626
703
  return
627
704
 
@@ -629,7 +706,7 @@ class Player(pygame.sprite.Sprite):
629
706
  self.x += dx
630
707
  self.x = min(LEVEL_WIDTH, max(0, self.x))
631
708
  self.rect.centerx = int(self.x)
632
- hit_list_x = spritecollide_walls(self, walls)
709
+ hit_list_x = spritecollide_walls(self, walls, wall_index=wall_index)
633
710
  if hit_list_x:
634
711
  damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_x))
635
712
  for wall in hit_list_x:
@@ -642,7 +719,7 @@ class Player(pygame.sprite.Sprite):
642
719
  self.y += dy
643
720
  self.y = min(LEVEL_HEIGHT, max(0, self.y))
644
721
  self.rect.centery = int(self.y)
645
- hit_list_y = spritecollide_walls(self, walls)
722
+ hit_list_y = spritecollide_walls(self, walls, wall_index=wall_index)
646
723
  if hit_list_y:
647
724
  damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_y))
648
725
  for wall in hit_list_y:
@@ -654,18 +731,20 @@ class Player(pygame.sprite.Sprite):
654
731
  self.rect.center = (int(self.x), int(self.y))
655
732
 
656
733
 
657
- class Companion(pygame.sprite.Sprite):
658
- """Simple survivor sprite used in Stage 3."""
734
+ class Survivor(pygame.sprite.Sprite):
735
+ """Civilians that gather near the player; optional buddy behavior."""
659
736
 
660
- def __init__(self: Self, x: float, y: float) -> None:
737
+ def __init__(self: Self, x: float, y: float, *, is_buddy: bool = False) -> None:
661
738
  super().__init__()
662
- self.radius = COMPANION_RADIUS
739
+ self.is_buddy = is_buddy
740
+ self.radius = BUDDY_RADIUS if is_buddy else SURVIVOR_RADIUS
663
741
  self.image = pygame.Surface((self.radius * 2, self.radius * 2), pygame.SRCALPHA)
742
+ fill_color = BUDDY_COLOR if is_buddy else SURVIVOR_COLOR
664
743
  _draw_outlined_circle(
665
744
  self.image,
666
745
  (self.radius, self.radius),
667
746
  self.radius,
668
- COMPANION_COLOR,
747
+ fill_color,
669
748
  HUMANOID_OUTLINE_COLOR,
670
749
  HUMANOID_OUTLINE_WIDTH,
671
750
  )
@@ -676,88 +755,71 @@ class Companion(pygame.sprite.Sprite):
676
755
  self.rescued = False
677
756
 
678
757
  def set_following(self: Self) -> None:
679
- if not self.rescued:
758
+ if self.is_buddy and not self.rescued:
680
759
  self.following = True
681
760
 
682
761
  def mark_rescued(self: Self) -> None:
683
- self.following = False
684
- self.rescued = True
762
+ if self.is_buddy:
763
+ self.following = False
764
+ self.rescued = True
685
765
 
686
766
  def teleport(self: Self, pos: tuple[int, int]) -> None:
687
- """Reposition the companion (used for quiet respawns)."""
767
+ """Reposition the survivor (used for quiet respawns)."""
688
768
  self.x, self.y = float(pos[0]), float(pos[1])
689
769
  self.rect.center = (int(self.x), int(self.y))
690
- self.following = False
770
+ if self.is_buddy:
771
+ self.following = False
691
772
 
692
- def update_follow(
693
- self: Self, target_pos: tuple[float, float], walls: pygame.sprite.Group
773
+ def update_behavior(
774
+ self: Self,
775
+ player_pos: tuple[int, int],
776
+ walls: pygame.sprite.Group,
777
+ *,
778
+ wall_index: WallIndex | None = None,
694
779
  ) -> None:
695
- """Follow the target at a slightly slower speed than the player."""
696
- if self.rescued or not self.following:
697
- self.rect.center = (int(self.x), int(self.y))
698
- return
780
+ if self.is_buddy:
781
+ if self.rescued or not self.following:
782
+ self.rect.center = (int(self.x), int(self.y))
783
+ return
699
784
 
700
- dx = target_pos[0] - self.x
701
- dy = target_pos[1] - self.y
702
- dist = math.hypot(dx, dy)
703
- if dist <= 0:
704
- self.rect.center = (int(self.x), int(self.y))
705
- return
785
+ dx = player_pos[0] - self.x
786
+ dy = player_pos[1] - self.y
787
+ dist = math.hypot(dx, dy)
788
+ if dist <= 0:
789
+ self.rect.center = (int(self.x), int(self.y))
790
+ return
706
791
 
707
- move_x = (dx / dist) * COMPANION_FOLLOW_SPEED
708
- move_y = (dy / dist) * COMPANION_FOLLOW_SPEED
792
+ move_x = (dx / dist) * BUDDY_FOLLOW_SPEED
793
+ move_y = (dy / dist) * BUDDY_FOLLOW_SPEED
709
794
 
710
- if move_x != 0:
711
- self.x += move_x
712
- self.rect.centerx = int(self.x)
713
- if spritecollideany_walls(self, walls):
714
- self.x -= move_x
795
+ if move_x:
796
+ self.x += move_x
715
797
  self.rect.centerx = int(self.x)
716
- if move_y != 0:
717
- self.y += move_y
718
- self.rect.centery = int(self.y)
719
- if spritecollideany_walls(self, walls):
720
- self.y -= move_y
798
+ if spritecollideany_walls(self, walls, wall_index=wall_index):
799
+ self.x -= move_x
800
+ self.rect.centerx = int(self.x)
801
+ if move_y:
802
+ self.y += move_y
721
803
  self.rect.centery = int(self.y)
804
+ if spritecollideany_walls(self, walls, wall_index=wall_index):
805
+ self.y -= move_y
806
+ self.rect.centery = int(self.y)
807
+
808
+ overlap_radius = (self.radius + PLAYER_RADIUS) * 1.05
809
+ dx_after = player_pos[0] - self.x
810
+ dy_after = player_pos[1] - self.y
811
+ dist_after = math.hypot(dx_after, dy_after)
812
+ if dist_after > 0 and dist_after < overlap_radius:
813
+ push_dist = overlap_radius - dist_after
814
+ self.x -= (dx_after / dist_after) * push_dist
815
+ self.y -= (dy_after / dist_after) * push_dist
816
+ self.rect.center = (int(self.x), int(self.y))
722
817
 
723
- # Avoid fully overlapping the player target
724
- overlap_radius = (self.radius + PLAYER_RADIUS) * 1.05
725
- dx_after = target_pos[0] - self.x
726
- dy_after = target_pos[1] - self.y
727
- dist_after = math.hypot(dx_after, dy_after)
728
- if dist_after > 0 and dist_after < overlap_radius:
729
- push_dist = overlap_radius - dist_after
730
- self.x -= (dx_after / dist_after) * push_dist
731
- self.y -= (dy_after / dist_after) * push_dist
818
+ self.x = min(LEVEL_WIDTH, max(0, self.x))
819
+ self.y = min(LEVEL_HEIGHT, max(0, self.y))
732
820
  self.rect.center = (int(self.x), int(self.y))
821
+ return
733
822
 
734
- self.x = min(LEVEL_WIDTH, max(0, self.x))
735
- self.y = min(LEVEL_HEIGHT, max(0, self.y))
736
- self.rect.center = (int(self.x), int(self.y))
737
-
738
-
739
- class Survivor(pygame.sprite.Sprite):
740
- """Civilians that gather near the player during Stage 4."""
741
-
742
- def __init__(self: Self, x: float, y: float) -> None:
743
- super().__init__()
744
- self.radius = SURVIVOR_RADIUS
745
- self.image = pygame.Surface((self.radius * 2, self.radius * 2), pygame.SRCALPHA)
746
- _draw_outlined_circle(
747
- self.image,
748
- (self.radius, self.radius),
749
- self.radius,
750
- SURVIVOR_COLOR,
751
- HUMANOID_OUTLINE_COLOR,
752
- HUMANOID_OUTLINE_WIDTH,
753
- )
754
- self.rect = self.image.get_rect(center=(int(x), int(y)))
755
- self.x = float(self.rect.centerx)
756
- self.y = float(self.rect.centery)
757
-
758
- def update_behavior(
759
- self: Self, player_pos: tuple[int, int], walls: pygame.sprite.Group
760
- ) -> None:
761
823
  dx = player_pos[0] - self.x
762
824
  dy = player_pos[1] - self.y
763
825
  dist = math.hypot(dx, dy)
@@ -770,13 +832,13 @@ class Survivor(pygame.sprite.Sprite):
770
832
  if move_x:
771
833
  self.x += move_x
772
834
  self.rect.centerx = int(self.x)
773
- if spritecollideany_walls(self, walls):
835
+ if spritecollideany_walls(self, walls, wall_index=wall_index):
774
836
  self.x -= move_x
775
837
  self.rect.centerx = int(self.x)
776
838
  if move_y:
777
839
  self.y += move_y
778
840
  self.rect.centery = int(self.y)
779
- if spritecollideany_walls(self, walls):
841
+ if spritecollideany_walls(self, walls, wall_index=wall_index):
780
842
  self.y -= move_y
781
843
  self.rect.centery = int(self.y)
782
844
 
@@ -832,6 +894,38 @@ def zombie_wall_follow_has_wall(
832
894
  )
833
895
 
834
896
 
897
+ def zombie_wall_follow_wall_distance(
898
+ zombie: Zombie,
899
+ walls: list[Wall],
900
+ angle: float,
901
+ max_distance: float,
902
+ *,
903
+ step: float = ZOMBIE_WALL_FOLLOW_PROBE_STEP,
904
+ ) -> float:
905
+ direction_x = math.cos(angle)
906
+ direction_y = math.sin(angle)
907
+ max_search = max_distance + 120
908
+ candidates = [
909
+ wall
910
+ for wall in walls
911
+ if abs(wall.rect.centerx - zombie.x) < max_search
912
+ and abs(wall.rect.centery - zombie.y) < max_search
913
+ ]
914
+ if not candidates:
915
+ return max_distance
916
+ distance = step
917
+ while distance <= max_distance:
918
+ check_x = zombie.x + direction_x * distance
919
+ check_y = zombie.y + direction_y * distance
920
+ if any(
921
+ circle_wall_collision((check_x, check_y), zombie.radius, wall)
922
+ for wall in candidates
923
+ ):
924
+ return distance
925
+ distance += step
926
+ return max_distance
927
+
928
+
835
929
  def zombie_wall_follow_movement(
836
930
  zombie: Zombie,
837
931
  player_center: tuple[int, int],
@@ -844,22 +938,28 @@ def zombie_wall_follow_movement(
844
938
  if zombie.wall_follow_side == 0:
845
939
  sensor_distance = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius
846
940
  forward_angle = zombie.wall_follow_angle
847
- left_angle = forward_angle + math.pi / 2.0
848
- right_angle = forward_angle - math.pi / 2.0
849
- left_wall = zombie_wall_follow_has_wall(
941
+ probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
942
+ left_angle = forward_angle + probe_offset
943
+ right_angle = forward_angle - probe_offset
944
+ left_dist = zombie_wall_follow_wall_distance(
850
945
  zombie, walls, left_angle, sensor_distance
851
946
  )
852
- right_wall = zombie_wall_follow_has_wall(
947
+ right_dist = zombie_wall_follow_wall_distance(
853
948
  zombie, walls, right_angle, sensor_distance
854
949
  )
855
- forward_wall = zombie_wall_follow_has_wall(
950
+ forward_dist = zombie_wall_follow_wall_distance(
856
951
  zombie, walls, forward_angle, sensor_distance
857
952
  )
953
+ left_wall = left_dist < sensor_distance
954
+ right_wall = right_dist < sensor_distance
955
+ forward_wall = forward_dist < sensor_distance
858
956
  if left_wall or right_wall or forward_wall:
859
957
  if left_wall and not right_wall:
860
958
  zombie.wall_follow_side = 1.0
861
959
  elif right_wall and not left_wall:
862
960
  zombie.wall_follow_side = -1.0
961
+ elif left_wall and right_wall:
962
+ zombie.wall_follow_side = 1.0 if left_dist <= right_dist else -1.0
863
963
  else:
864
964
  zombie.wall_follow_side = RNG.choice([-1.0, 1.0])
865
965
  zombie.wall_follow_last_wall_time = pygame.time.get_ticks()
@@ -870,13 +970,16 @@ def zombie_wall_follow_movement(
870
970
  return zombie_wander_move(zombie, walls)
871
971
 
872
972
  sensor_distance = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius
873
- side_angle = zombie.wall_follow_angle + zombie.wall_follow_side * (math.pi / 2.0)
874
- side_has_wall = zombie_wall_follow_has_wall(
973
+ probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
974
+ side_angle = zombie.wall_follow_angle + zombie.wall_follow_side * probe_offset
975
+ side_dist = zombie_wall_follow_wall_distance(
875
976
  zombie, walls, side_angle, sensor_distance
876
977
  )
877
- forward_has_wall = zombie_wall_follow_has_wall(
978
+ forward_dist = zombie_wall_follow_wall_distance(
878
979
  zombie, walls, zombie.wall_follow_angle, sensor_distance
879
980
  )
981
+ side_has_wall = side_dist < sensor_distance
982
+ forward_has_wall = forward_dist < sensor_distance
880
983
  now = pygame.time.get_ticks()
881
984
  wall_recent = (
882
985
  zombie.wall_follow_last_wall_time is not None
@@ -890,16 +993,22 @@ def zombie_wall_follow_movement(
890
993
  zombie.wall_follow_last_wall_time = now
891
994
  if side_has_wall:
892
995
  zombie.wall_follow_last_side_has_wall = True
996
+ gap_error = ZOMBIE_WALL_FOLLOW_TARGET_GAP - side_dist
997
+ if abs(gap_error) > 0.1:
998
+ ratio = min(1.0, abs(gap_error) / ZOMBIE_WALL_FOLLOW_TARGET_GAP)
999
+ turn = turn_step * ratio
1000
+ if gap_error > 0:
1001
+ zombie.wall_follow_angle -= zombie.wall_follow_side * turn
1002
+ else:
1003
+ zombie.wall_follow_angle += zombie.wall_follow_side * turn
1004
+ if forward_dist < ZOMBIE_WALL_FOLLOW_TARGET_GAP:
1005
+ zombie.wall_follow_angle -= zombie.wall_follow_side * (turn_step * 1.5)
1006
+ else:
1007
+ zombie.wall_follow_last_side_has_wall = False
893
1008
  if forward_has_wall:
894
1009
  zombie.wall_follow_angle -= zombie.wall_follow_side * turn_step
895
- else:
896
- if wall_recent:
897
- if zombie.wall_follow_last_side_has_wall and not forward_has_wall:
898
- zombie.wall_follow_angle += zombie.wall_follow_side * (math.pi / 2.0)
899
- zombie.wall_follow_last_side_has_wall = False
900
- elif zombie.wall_follow_last_side_has_wall:
901
- zombie.wall_follow_angle += zombie.wall_follow_side * turn_step
902
- zombie.wall_follow_last_side_has_wall = False
1010
+ elif wall_recent:
1011
+ zombie.wall_follow_angle += zombie.wall_follow_side * (turn_step * 0.75)
903
1012
  else:
904
1013
  zombie.wall_follow_angle += zombie.wall_follow_side * (math.pi / 2.0)
905
1014
  zombie.wall_follow_side = 0.0
@@ -1037,7 +1146,20 @@ class Zombie(pygame.sprite.Sprite):
1037
1146
  super().__init__()
1038
1147
  self.radius = ZOMBIE_RADIUS
1039
1148
  self.image = pygame.Surface((self.radius * 2, self.radius * 2), pygame.SRCALPHA)
1040
- pygame.draw.circle(self.image, RED, (self.radius, self.radius), self.radius)
1149
+ if tracker:
1150
+ outline_color = TRACKER_OUTLINE_COLOR
1151
+ elif wall_follower:
1152
+ outline_color = WALL_FOLLOWER_OUTLINE_COLOR
1153
+ else:
1154
+ outline_color = DARK_RED
1155
+ _draw_outlined_circle(
1156
+ self.image,
1157
+ (self.radius, self.radius),
1158
+ self.radius,
1159
+ RED,
1160
+ outline_color,
1161
+ 1,
1162
+ )
1041
1163
  if start_pos:
1042
1164
  x, y = start_pos
1043
1165
  elif hint_pos:
@@ -1443,7 +1565,6 @@ __all__ = [
1443
1565
  "SteelBeam",
1444
1566
  "Camera",
1445
1567
  "Player",
1446
- "Companion",
1447
1568
  "Survivor",
1448
1569
  "Zombie",
1449
1570
  "Car",