zombie-escape 1.10.1__py3-none-any.whl → 1.12.3__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.
@@ -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.10.1"
4
+ __version__ = "1.12.3"
zombie_escape/colors.py CHANGED
@@ -2,7 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
 
5
- # Basic palette
5
+
6
+ def _clamp(value: float) -> int:
7
+ return max(0, min(255, int(value)))
8
+
9
+
10
+ # Basic palette.
6
11
  WHITE: tuple[int, int, int] = (255, 255, 255)
7
12
  BLACK: tuple[int, int, int] = (0, 0, 0)
8
13
  RED: tuple[int, int, int] = (255, 0, 0)
@@ -13,8 +18,6 @@ LIGHT_GRAY: tuple[int, int, int] = (200, 200, 200)
13
18
  YELLOW: tuple[int, int, int] = (255, 255, 0)
14
19
  ORANGE: tuple[int, int, int] = (255, 165, 0)
15
20
  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)
18
21
 
19
22
 
20
23
  @dataclass(frozen=True)
@@ -32,10 +35,6 @@ class EnvironmentPalette:
32
35
  outer_wall_border: tuple[int, int, int]
33
36
 
34
37
 
35
- def _clamp(value: float) -> int:
36
- return max(0, min(255, int(value)))
37
-
38
-
39
38
  def _adjust_color(
40
39
  color: tuple[int, int, int], *, brightness: float = 1.0, saturation: float = 1.0
41
40
  ) -> tuple[int, int, int]:
@@ -56,6 +55,7 @@ DEFAULT_AMBIENT_PALETTE_KEY = "default"
56
55
  NO_FLASHLIGHT_PALETTE_KEY = "no_flashlight"
57
56
  DAWN_AMBIENT_PALETTE_KEY = "dawn"
58
57
 
58
+
59
59
  # Base palette used throughout gameplay (matches the previous constants).
60
60
  _DEFAULT_ENVIRONMENT_PALETTE = EnvironmentPalette(
61
61
  floor_primary=(43, 57, 70),
@@ -72,10 +72,14 @@ _DEFAULT_ENVIRONMENT_PALETTE = EnvironmentPalette(
72
72
  # Dark, desaturated palette that sells the "alone without a flashlight" vibe.
73
73
  _GLOOM_ENVIRONMENT_PALETTE = EnvironmentPalette(
74
74
  floor_primary=_adjust_color(
75
- _DEFAULT_ENVIRONMENT_PALETTE.floor_primary, brightness=0.8, saturation=0.75
75
+ _DEFAULT_ENVIRONMENT_PALETTE.floor_primary,
76
+ brightness=0.8,
77
+ saturation=0.75,
76
78
  ),
77
79
  floor_secondary=_adjust_color(
78
- _DEFAULT_ENVIRONMENT_PALETTE.floor_secondary, brightness=0.8, saturation=0.75
80
+ _DEFAULT_ENVIRONMENT_PALETTE.floor_secondary,
81
+ brightness=0.8,
82
+ saturation=0.75,
79
83
  ),
80
84
  fall_zone_primary=_adjust_color(
81
85
  _DEFAULT_ENVIRONMENT_PALETTE.fall_zone_primary,
@@ -88,10 +92,14 @@ _GLOOM_ENVIRONMENT_PALETTE = EnvironmentPalette(
88
92
  saturation=0.75,
89
93
  ),
90
94
  outside=_adjust_color(
91
- _DEFAULT_ENVIRONMENT_PALETTE.outside, brightness=0.8, saturation=0.75
95
+ _DEFAULT_ENVIRONMENT_PALETTE.outside,
96
+ brightness=0.8,
97
+ saturation=0.75,
92
98
  ),
93
99
  inner_wall=_adjust_color(
94
- _DEFAULT_ENVIRONMENT_PALETTE.inner_wall, brightness=0.8, saturation=0.75
100
+ _DEFAULT_ENVIRONMENT_PALETTE.inner_wall,
101
+ brightness=0.8,
102
+ saturation=0.75,
95
103
  ),
96
104
  inner_wall_border=_adjust_color(
97
105
  _DEFAULT_ENVIRONMENT_PALETTE.inner_wall_border,
@@ -99,7 +107,9 @@ _GLOOM_ENVIRONMENT_PALETTE = EnvironmentPalette(
99
107
  saturation=0.75,
100
108
  ),
101
109
  outer_wall=_adjust_color(
102
- _DEFAULT_ENVIRONMENT_PALETTE.outer_wall, brightness=0.8, saturation=0.75
110
+ _DEFAULT_ENVIRONMENT_PALETTE.outer_wall,
111
+ brightness=0.8,
112
+ saturation=0.75,
103
113
  ),
104
114
  outer_wall_border=_adjust_color(
105
115
  _DEFAULT_ENVIRONMENT_PALETTE.outer_wall_border,
@@ -181,8 +191,6 @@ __all__ = [
181
191
  "YELLOW",
182
192
  "ORANGE",
183
193
  "DARK_RED",
184
- "TRACKER_OUTLINE_COLOR",
185
- "WALL_FOLLOWER_OUTLINE_COLOR",
186
194
  "DAWN_AMBIENT_PALETTE_KEY",
187
195
  "INTERNAL_WALL_COLOR",
188
196
  "INTERNAL_WALL_BORDER_COLOR",
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,11 +27,14 @@ from .entities_constants import (
26
27
  FLASHLIGHT_WIDTH,
27
28
  FUEL_CAN_HEIGHT,
28
29
  FUEL_CAN_WIDTH,
30
+ HUMANOID_WALL_BUMP_FRAMES,
29
31
  INTERNAL_WALL_BEVEL_DEPTH,
30
32
  INTERNAL_WALL_HEALTH,
31
33
  PLAYER_RADIUS,
32
34
  PLAYER_SPEED,
33
35
  PLAYER_WALL_DAMAGE,
36
+ SHOES_HEIGHT,
37
+ SHOES_WIDTH,
34
38
  STEEL_BEAM_HEALTH,
35
39
  SURVIVOR_APPROACH_RADIUS,
36
40
  SURVIVOR_APPROACH_SPEED,
@@ -57,19 +61,24 @@ from .entities_constants import (
57
61
  )
58
62
  from .gameplay.constants import FOOTPRINT_STEP_DISTANCE
59
63
  from .models import Footprint
64
+ from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
60
65
  from .render_assets import (
61
66
  EnvironmentPalette,
67
+ angle_bin_from_vector,
62
68
  build_beveled_polygon,
69
+ build_car_directional_surfaces,
63
70
  build_car_surface,
64
71
  build_flashlight_surface,
65
72
  build_fuel_can_surface,
66
- build_player_surface,
67
- build_survivor_surface,
68
- build_zombie_surface,
73
+ build_player_directional_surfaces,
74
+ build_shoes_surface,
75
+ build_survivor_directional_surfaces,
76
+ build_zombie_directional_surfaces,
77
+ draw_humanoid_hand,
78
+ draw_humanoid_nose,
69
79
  paint_car_surface,
70
80
  paint_steel_beam_surface,
71
81
  paint_wall_surface,
72
- paint_zombie_surface,
73
82
  resolve_car_color,
74
83
  resolve_steel_beam_colors,
75
84
  resolve_wall_colors,
@@ -226,6 +235,14 @@ class SteelBeam(pygame.sprite.Sprite):
226
235
  )
227
236
 
228
237
 
238
+ def _is_inner_wall(wall: pygame.sprite.Sprite) -> bool:
239
+ if isinstance(wall, SteelBeam):
240
+ return True
241
+ if isinstance(wall, Wall):
242
+ return wall.palette_category == "inner_wall"
243
+ return False
244
+
245
+
229
246
  MovementStrategy = Callable[
230
247
  [
231
248
  "Zombie",
@@ -239,6 +256,8 @@ MovementStrategy = Callable[
239
256
  ],
240
257
  tuple[float, float],
241
258
  ]
259
+
260
+
242
261
  def _sprite_center_and_radius(
243
262
  sprite: pygame.sprite.Sprite,
244
263
  ) -> tuple[tuple[int, int], float]:
@@ -564,7 +583,14 @@ class Player(pygame.sprite.Sprite):
564
583
  ) -> None:
565
584
  super().__init__()
566
585
  self.radius = PLAYER_RADIUS
567
- self.image = build_player_surface(self.radius)
586
+ self.facing_bin = 0
587
+ self.input_facing_bin = 0
588
+ self.wall_bump_counter = 0
589
+ self.wall_bump_flip = 1
590
+ self.inner_wall_hit = False
591
+ self.inner_wall_cell = None
592
+ self.directional_images = build_player_directional_surfaces(self.radius)
593
+ self.image = self.directional_images[self.facing_bin]
568
594
  self.rect = self.image.get_rect(center=(x, y))
569
595
  self.speed = PLAYER_SPEED
570
596
  self.in_car = False
@@ -588,6 +614,9 @@ class Player(pygame.sprite.Sprite):
588
614
  if level_width is None or level_height is None:
589
615
  raise ValueError("level_width/level_height are required for movement")
590
616
 
617
+ inner_wall_hit = False
618
+ inner_wall_cell: tuple[int, int] | None = None
619
+
591
620
  if dx != 0:
592
621
  self.x += dx
593
622
  self.x = min(level_width, max(0, self.x))
@@ -603,6 +632,13 @@ class Player(pygame.sprite.Sprite):
603
632
  for wall in hit_list_x:
604
633
  if wall.alive():
605
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
+ )
606
642
  self.x -= dx * 1.5
607
643
  self.rect.centerx = int(self.x)
608
644
 
@@ -621,10 +657,51 @@ class Player(pygame.sprite.Sprite):
621
657
  for wall in hit_list_y:
622
658
  if wall.alive():
623
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
+ )
624
667
  self.y -= dy * 1.5
625
668
  self.rect.centery = int(self.y)
626
669
 
627
670
  self.rect.center = (int(self.x), int(self.y))
671
+ self.inner_wall_hit = inner_wall_hit
672
+ self.inner_wall_cell = inner_wall_cell
673
+ self._update_facing_for_bump(inner_wall_hit)
674
+
675
+ def update_facing_from_input(self: Self, dx: float, dy: float) -> None:
676
+ if self.in_car:
677
+ return
678
+ new_bin = angle_bin_from_vector(dx, dy)
679
+ if new_bin is None:
680
+ return
681
+ self.input_facing_bin = new_bin
682
+
683
+ def _update_facing_for_bump(self: Self, inner_wall_hit: bool) -> None:
684
+ if self.in_car:
685
+ return
686
+ if inner_wall_hit:
687
+ self.wall_bump_counter += 1
688
+ if self.wall_bump_counter % HUMANOID_WALL_BUMP_FRAMES == 0:
689
+ self.wall_bump_flip *= -1
690
+ bumped_bin = (self.input_facing_bin + self.wall_bump_flip) % ANGLE_BINS
691
+ self._set_facing_bin(bumped_bin)
692
+ return
693
+ if self.wall_bump_counter:
694
+ self.wall_bump_counter = 0
695
+ self.wall_bump_flip = 1
696
+ self._set_facing_bin(self.input_facing_bin)
697
+
698
+ def _set_facing_bin(self: Self, new_bin: int) -> None:
699
+ if new_bin == self.facing_bin:
700
+ return
701
+ center = self.rect.center
702
+ self.facing_bin = new_bin
703
+ self.image = self.directional_images[self.facing_bin]
704
+ self.rect = self.image.get_rect(center=center)
628
705
 
629
706
 
630
707
  class Survivor(pygame.sprite.Sprite):
@@ -640,10 +717,16 @@ class Survivor(pygame.sprite.Sprite):
640
717
  super().__init__()
641
718
  self.is_buddy = is_buddy
642
719
  self.radius = BUDDY_RADIUS if is_buddy else SURVIVOR_RADIUS
643
- self.image = build_survivor_surface(
720
+ self.facing_bin = 0
721
+ self.input_facing_bin = 0
722
+ self.wall_bump_counter = 0
723
+ self.wall_bump_flip = 1
724
+ self.directional_images = build_survivor_directional_surfaces(
644
725
  self.radius,
645
726
  is_buddy=is_buddy,
727
+ draw_hands=is_buddy,
646
728
  )
729
+ self.image = self.directional_images[self.facing_bin]
647
730
  self.rect = self.image.get_rect(center=(int(x), int(y)))
648
731
  self.x = float(self.rect.centerx)
649
732
  self.y = float(self.rect.centery)
@@ -680,6 +763,7 @@ class Survivor(pygame.sprite.Sprite):
680
763
  grid_rows: int | None = None,
681
764
  level_width: int | None = None,
682
765
  level_height: int | None = None,
766
+ wall_target_cell: tuple[int, int] | None = None,
683
767
  ) -> None:
684
768
  if level_width is None or level_height is None:
685
769
  raise ValueError("level_width/level_height are required for movement")
@@ -688,11 +772,19 @@ class Survivor(pygame.sprite.Sprite):
688
772
  self.rect.center = (int(self.x), int(self.y))
689
773
  return
690
774
 
691
- dx = player_pos[0] - self.x
692
- dy = player_pos[1] - self.y
775
+ target_pos = player_pos
776
+ if wall_target_cell is not None and cell_size is not None:
777
+ target_pos = (
778
+ wall_target_cell[0] * cell_size + cell_size // 2,
779
+ wall_target_cell[1] * cell_size + cell_size // 2,
780
+ )
781
+
782
+ dx = target_pos[0] - self.x
783
+ dy = target_pos[1] - self.y
693
784
  dist_sq = dx * dx + dy * dy
694
785
  if dist_sq <= 0:
695
786
  self.rect.center = (int(self.x), int(self.y))
787
+ self._update_facing_for_bump(False)
696
788
  return
697
789
 
698
790
  dist = math.sqrt(dist_sq)
@@ -717,32 +809,45 @@ class Survivor(pygame.sprite.Sprite):
717
809
  grid_rows=grid_rows,
718
810
  )
719
811
 
812
+ self._update_input_facing(move_x, move_y)
813
+ inner_wall_hit = False
814
+
720
815
  if move_x:
721
816
  self.x += move_x
722
817
  self.rect.centerx = int(self.x)
723
- if spritecollideany_walls(
818
+ wall = spritecollideany_walls(
724
819
  self,
725
820
  walls,
726
821
  wall_index=wall_index,
727
822
  cell_size=cell_size,
728
- ):
823
+ )
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
729
829
  self.x -= move_x
730
830
  self.rect.centerx = int(self.x)
731
831
  if move_y:
732
832
  self.y += move_y
733
833
  self.rect.centery = int(self.y)
734
- if spritecollideany_walls(
834
+ wall = spritecollideany_walls(
735
835
  self,
736
836
  walls,
737
837
  wall_index=wall_index,
738
838
  cell_size=cell_size,
739
- ):
839
+ )
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
740
845
  self.y -= move_y
741
846
  self.rect.centery = int(self.y)
742
847
 
743
848
  overlap_radius = (self.radius + PLAYER_RADIUS) * 1.05
744
- dx_after = player_pos[0] - self.x
745
- dy_after = player_pos[1] - self.y
849
+ dx_after = target_pos[0] - self.x
850
+ dy_after = target_pos[1] - self.y
746
851
  dist_after_sq = dx_after * dx_after + dy_after * dy_after
747
852
  if 0 < dist_after_sq < overlap_radius * overlap_radius:
748
853
  dist_after = math.sqrt(dist_after_sq)
@@ -754,6 +859,7 @@ class Survivor(pygame.sprite.Sprite):
754
859
  self.x = min(level_width, max(0, self.x))
755
860
  self.y = min(level_height, max(0, self.y))
756
861
  self.rect.center = (int(self.x), int(self.y))
862
+ self._update_facing_for_bump(inner_wall_hit)
757
863
  return
758
864
 
759
865
  dx = player_pos[0] - self.x
@@ -769,6 +875,8 @@ class Survivor(pygame.sprite.Sprite):
769
875
  move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
770
876
  move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
771
877
 
878
+ self._update_input_facing(move_x, move_y)
879
+
772
880
  if (
773
881
  cell_size is not None
774
882
  and wall_cells is not None
@@ -811,6 +919,37 @@ class Survivor(pygame.sprite.Sprite):
811
919
  self.rect.centery = int(self.y)
812
920
 
813
921
  self.rect.center = (int(self.x), int(self.y))
922
+ self._update_facing_for_bump(False)
923
+
924
+ def _update_input_facing(self: Self, dx: float, dy: float) -> None:
925
+ new_bin = angle_bin_from_vector(dx, dy)
926
+ if new_bin is None:
927
+ return
928
+ self.input_facing_bin = new_bin
929
+
930
+ def _update_facing_for_bump(self: Self, inner_wall_hit: bool) -> None:
931
+ if not self.is_buddy:
932
+ self._set_facing_bin(self.input_facing_bin)
933
+ return
934
+ if inner_wall_hit:
935
+ self.wall_bump_counter += 1
936
+ if self.wall_bump_counter % HUMANOID_WALL_BUMP_FRAMES == 0:
937
+ self.wall_bump_flip *= -1
938
+ bumped_bin = (self.input_facing_bin + self.wall_bump_flip) % ANGLE_BINS
939
+ self._set_facing_bin(bumped_bin)
940
+ return
941
+ if self.wall_bump_counter:
942
+ self.wall_bump_counter = 0
943
+ self.wall_bump_flip = 1
944
+ self._set_facing_bin(self.input_facing_bin)
945
+
946
+ def _set_facing_bin(self: Self, new_bin: int) -> None:
947
+ if new_bin == self.facing_bin:
948
+ return
949
+ center = self.rect.center
950
+ self.facing_bin = new_bin
951
+ self.image = self.directional_images[self.facing_bin]
952
+ self.rect = self.image.get_rect(center=center)
814
953
 
815
954
 
816
955
  def random_position_outside_building(
@@ -1221,13 +1360,15 @@ class Zombie(pygame.sprite.Sprite):
1221
1360
  ) -> None:
1222
1361
  super().__init__()
1223
1362
  self.radius = ZOMBIE_RADIUS
1363
+ self.facing_bin = 0
1224
1364
  self.tracker = tracker
1225
1365
  self.wall_follower = wall_follower
1226
1366
  self.carbonized = False
1227
- self.image = build_zombie_surface(
1228
- self.radius, tracker=self.tracker, wall_follower=self.wall_follower
1367
+ self.directional_images = build_zombie_directional_surfaces(
1368
+ self.radius,
1369
+ draw_hands=False,
1229
1370
  )
1230
- self._redraw_image()
1371
+ self.image = self.directional_images[self.facing_bin]
1231
1372
  self.rect = self.image.get_rect(center=(x, y))
1232
1373
  jitter_base = FAST_ZOMBIE_BASE_SPEED if speed > ZOMBIE_SPEED else ZOMBIE_SPEED
1233
1374
  jitter = jitter_base * 0.2
@@ -1266,15 +1407,6 @@ class Zombie(pygame.sprite.Sprite):
1266
1407
  0, self.wander_interval_ms + RNG.randint(-500, 500)
1267
1408
  )
1268
1409
 
1269
- def _redraw_image(self: Self, palm_angle: float | None = None) -> None:
1270
- paint_zombie_surface(
1271
- self.image,
1272
- radius=self.radius,
1273
- palm_angle=palm_angle,
1274
- tracker=self.tracker,
1275
- wall_follower=self.wall_follower,
1276
- )
1277
-
1278
1410
  def _update_mode(
1279
1411
  self: Self, player_center: tuple[int, int], sight_range: float
1280
1412
  ) -> bool:
@@ -1381,6 +1513,53 @@ class Zombie(pygame.sprite.Sprite):
1381
1513
  slowdown_ratio = 1.0 - progress * (1.0 - ZOMBIE_AGING_MIN_SPEED_RATIO)
1382
1514
  self.speed = self.initial_speed * slowdown_ratio
1383
1515
 
1516
+ def _set_facing_bin(self: Self, new_bin: int) -> None:
1517
+ if new_bin == self.facing_bin:
1518
+ return
1519
+ center = self.rect.center
1520
+ self.facing_bin = new_bin
1521
+ self.image = self.directional_images[self.facing_bin]
1522
+ self.rect = self.image.get_rect(center=center)
1523
+
1524
+ def _update_facing_from_movement(self: Self, dx: float, dy: float) -> None:
1525
+ new_bin = angle_bin_from_vector(dx, dy)
1526
+ if new_bin is None:
1527
+ return
1528
+ self._set_facing_bin(new_bin)
1529
+
1530
+ def _apply_render_overlays(self: Self) -> None:
1531
+ base_surface = self.directional_images[self.facing_bin]
1532
+ 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
1536
+ )
1537
+ if not needs_overlay:
1538
+ self.image = base_surface
1539
+ return
1540
+ self.image = base_surface.copy()
1541
+ angle_rad = (self.facing_bin % ANGLE_BINS) * (math.tau / ANGLE_BINS)
1542
+ if self.tracker:
1543
+ draw_humanoid_nose(
1544
+ self.image,
1545
+ radius=self.radius,
1546
+ angle_rad=angle_rad,
1547
+ color=ZOMBIE_NOSE_COLOR,
1548
+ )
1549
+ if (
1550
+ self.wall_follower
1551
+ and self.wall_follow_side != 0
1552
+ and self.wall_follow_last_side_has_wall
1553
+ ):
1554
+ side_sign = 1.0 if self.wall_follow_side > 0 else -1.0
1555
+ hand_angle = angle_rad + side_sign * (math.pi / 2.0)
1556
+ draw_humanoid_hand(
1557
+ self.image,
1558
+ radius=self.radius,
1559
+ angle_rad=hand_angle,
1560
+ color=ZOMBIE_NOSE_COLOR,
1561
+ )
1562
+
1384
1563
  def _update_stuck_state(self: Self) -> None:
1385
1564
  history = self.pos_history
1386
1565
  history.append((self.x, self.y))
@@ -1450,18 +1629,8 @@ class Zombie(pygame.sprite.Sprite):
1450
1629
  grid_cols=grid_cols,
1451
1630
  grid_rows=grid_rows,
1452
1631
  )
1453
- if self.wall_follower and self.wall_follow_side != 0:
1454
- if move_x != 0 or move_y != 0:
1455
- heading = math.atan2(move_y, move_x)
1456
- elif self.wall_follow_angle is not None:
1457
- heading = self.wall_follow_angle
1458
- else:
1459
- heading = self.wander_angle
1460
- if self.wall_follow_side > 0:
1461
- palm_angle = heading + (math.pi / 2.0)
1462
- else:
1463
- palm_angle = heading - (math.pi / 2.0)
1464
- self._redraw_image(palm_angle)
1632
+ self._update_facing_from_movement(move_x, move_y)
1633
+ self._apply_render_overlays()
1465
1634
  final_x, final_y = self._handle_wall_collision(
1466
1635
  self.x + move_x, self.y + move_y, walls
1467
1636
  )
@@ -1479,23 +1648,31 @@ class Zombie(pygame.sprite.Sprite):
1479
1648
  return
1480
1649
  self.carbonized = True
1481
1650
  self.speed = 0
1651
+ self.image = self.directional_images[self.facing_bin].copy()
1482
1652
  self.image.fill((0, 0, 0, 0))
1483
1653
  color = (80, 80, 80)
1484
- pygame.draw.circle(self.image, color, (self.radius, self.radius), self.radius)
1654
+ center = self.image.get_rect().center
1655
+ pygame.draw.circle(self.image, color, center, self.radius)
1485
1656
  pygame.draw.circle(
1486
- self.image, (30, 30, 30), (self.radius, self.radius), self.radius, width=1
1657
+ self.image,
1658
+ (30, 30, 30),
1659
+ center,
1660
+ self.radius,
1661
+ width=1,
1487
1662
  )
1488
1663
 
1489
1664
 
1490
1665
  class Car(pygame.sprite.Sprite):
1491
1666
  def __init__(self: Self, x: int, y: int, *, appearance: str = "default") -> None:
1492
1667
  super().__init__()
1668
+ self.facing_bin = ANGLE_BINS * 3 // 4
1669
+ self.input_facing_bin = self.facing_bin
1493
1670
  self.original_image = build_car_surface(CAR_WIDTH, CAR_HEIGHT)
1671
+ self.directional_images: list[pygame.Surface] = []
1494
1672
  self.appearance = appearance
1495
1673
  self.image = self.original_image.copy()
1496
1674
  self.rect = self.image.get_rect(center=(x, y))
1497
1675
  self.speed = CAR_SPEED
1498
- self.angle = 0
1499
1676
  self.x = float(self.rect.centerx)
1500
1677
  self.y = float(self.rect.centery)
1501
1678
  self.health = CAR_HEALTH
@@ -1517,10 +1694,28 @@ class Car(pygame.sprite.Sprite):
1517
1694
  height=CAR_HEIGHT,
1518
1695
  color=color,
1519
1696
  )
1520
- self.image = pygame.transform.rotate(self.original_image, self.angle)
1697
+ self.directional_images = build_car_directional_surfaces(self.original_image)
1698
+ self.image = self.directional_images[self.facing_bin]
1521
1699
  old_center = self.rect.center
1522
1700
  self.rect = self.image.get_rect(center=old_center)
1523
1701
 
1702
+ def update_facing_from_input(self: Self, dx: float, dy: float) -> None:
1703
+ new_bin = angle_bin_from_vector(dx, dy)
1704
+ if new_bin is None:
1705
+ return
1706
+ self.input_facing_bin = new_bin
1707
+ self._set_facing_bin(self.input_facing_bin)
1708
+
1709
+ def _set_facing_bin(self: Self, new_bin: int) -> None:
1710
+ if new_bin == self.facing_bin:
1711
+ return
1712
+ if not self.directional_images:
1713
+ return
1714
+ center = self.rect.center
1715
+ self.facing_bin = new_bin
1716
+ self.image = self.directional_images[self.facing_bin]
1717
+ self.rect = self.image.get_rect(center=center)
1718
+
1524
1719
  def move(
1525
1720
  self: Self,
1526
1721
  dx: float,
@@ -1534,11 +1729,6 @@ class Car(pygame.sprite.Sprite):
1534
1729
  if dx == 0 and dy == 0:
1535
1730
  self.rect.center = (int(self.x), int(self.y))
1536
1731
  return
1537
- target_angle = math.degrees(math.atan2(-dy, dx)) - 90
1538
- self.angle = target_angle
1539
- self.image = pygame.transform.rotate(self.original_image, self.angle)
1540
- old_center = (self.x, self.y)
1541
- self.rect = self.image.get_rect(center=old_center)
1542
1732
  new_x = self.x + dx
1543
1733
  new_y = self.y + dy
1544
1734
 
@@ -1589,6 +1779,15 @@ class Flashlight(pygame.sprite.Sprite):
1589
1779
  self.rect = self.image.get_rect(center=(x, y))
1590
1780
 
1591
1781
 
1782
+ class Shoes(pygame.sprite.Sprite):
1783
+ """Shoes pickup that boosts the player's move speed when collected."""
1784
+
1785
+ def __init__(self: Self, x: int, y: int) -> None:
1786
+ super().__init__()
1787
+ self.image = build_shoes_surface(SHOES_WIDTH, SHOES_HEIGHT)
1788
+ self.rect = self.image.get_rect(center=(x, y))
1789
+
1790
+
1592
1791
  def _car_body_radius(width: float, height: float) -> float:
1593
1792
  """Approximate car collision radius using only its own dimensions."""
1594
1793
  return min(width, height) / 2
@@ -1604,5 +1803,6 @@ __all__ = [
1604
1803
  "Car",
1605
1804
  "FuelCan",
1606
1805
  "Flashlight",
1806
+ "Shoes",
1607
1807
  "random_position_outside_building",
1608
1808
  ]
@@ -11,6 +11,7 @@ PLAYER_SPEED = 1.4
11
11
  FOV_RADIUS = 124 # approximate legacy FOV (80) * 1.55 cap
12
12
  BUDDY_RADIUS = HUMANOID_RADIUS
13
13
  BUDDY_FOLLOW_SPEED = PLAYER_SPEED * 0.7
14
+ HUMANOID_WALL_BUMP_FRAMES = 7
14
15
 
15
16
  # --- Survivor settings (Stage 4) ---
16
17
  SURVIVOR_RADIUS = HUMANOID_RADIUS
@@ -20,8 +21,12 @@ SURVIVOR_MAX_SAFE_PASSENGERS = 5
20
21
  SURVIVOR_MIN_SPEED_FACTOR = 0.35
21
22
 
22
23
  # --- Flashlight settings ---
23
- FLASHLIGHT_WIDTH = 10
24
- FLASHLIGHT_HEIGHT = 8
24
+ FLASHLIGHT_WIDTH = 12
25
+ FLASHLIGHT_HEIGHT = 10
26
+
27
+ # --- Shoes settings ---
28
+ SHOES_WIDTH = 14
29
+ SHOES_HEIGHT = 12
25
30
 
26
31
  # --- Zombie settings ---
27
32
  ZOMBIE_RADIUS = HUMANOID_RADIUS
@@ -45,12 +50,12 @@ ZOMBIE_WALL_FOLLOW_TARGET_GAP = 4.0
45
50
  ZOMBIE_WALL_FOLLOW_LOST_WALL_MS = 2500
46
51
 
47
52
  # --- Car and fuel settings ---
48
- CAR_WIDTH = 15
49
- CAR_HEIGHT = 25
53
+ CAR_WIDTH = 16
54
+ CAR_HEIGHT = 24
50
55
  CAR_SPEED = 2
51
56
  CAR_HEALTH = 20
52
57
  CAR_WALL_DAMAGE = 1
53
- FUEL_CAN_WIDTH = 11
58
+ FUEL_CAN_WIDTH = 12
54
59
  FUEL_CAN_HEIGHT = 15
55
60
 
56
61
  # --- Wall and beam settings ---
@@ -58,6 +63,7 @@ INTERNAL_WALL_HEALTH = 40 * 100
58
63
  INTERNAL_WALL_BEVEL_DEPTH = 6
59
64
  STEEL_BEAM_HEALTH = int(INTERNAL_WALL_HEALTH * 1.5)
60
65
  PLAYER_WALL_DAMAGE = 100
66
+ BUDDY_WALL_DAMAGE = int(PLAYER_WALL_DAMAGE * 0.7)
61
67
  ZOMBIE_WALL_DAMAGE = 1
62
68
 
63
69
  __all__ = [
@@ -67,6 +73,7 @@ __all__ = [
67
73
  "FOV_RADIUS",
68
74
  "BUDDY_RADIUS",
69
75
  "BUDDY_FOLLOW_SPEED",
76
+ "HUMANOID_WALL_BUMP_FRAMES",
70
77
  "SURVIVOR_RADIUS",
71
78
  "SURVIVOR_APPROACH_RADIUS",
72
79
  "SURVIVOR_APPROACH_SPEED",
@@ -74,6 +81,8 @@ __all__ = [
74
81
  "SURVIVOR_MIN_SPEED_FACTOR",
75
82
  "FLASHLIGHT_WIDTH",
76
83
  "FLASHLIGHT_HEIGHT",
84
+ "SHOES_WIDTH",
85
+ "SHOES_HEIGHT",
77
86
  "ZOMBIE_RADIUS",
78
87
  "ZOMBIE_SPEED",
79
88
  "ZOMBIE_WANDER_INTERVAL_MS",
@@ -104,5 +113,6 @@ __all__ = [
104
113
  "INTERNAL_WALL_BEVEL_DEPTH",
105
114
  "STEEL_BEAM_HEALTH",
106
115
  "PLAYER_WALL_DAMAGE",
116
+ "BUDDY_WALL_DAMAGE",
107
117
  "ZOMBIE_WALL_DAMAGE",
108
118
  ]