zombie-escape 1.12.0__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.12.0"
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,13 +27,14 @@ 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
33
  PLAYER_RADIUS,
34
34
  PLAYER_SPEED,
35
35
  PLAYER_WALL_DAMAGE,
36
+ SHOES_HEIGHT,
37
+ SHOES_WIDTH,
36
38
  STEEL_BEAM_HEALTH,
37
39
  SURVIVOR_APPROACH_RADIUS,
38
40
  SURVIVOR_APPROACH_SPEED,
@@ -59,20 +61,24 @@ from .entities_constants import (
59
61
  )
60
62
  from .gameplay.constants import FOOTPRINT_STEP_DISTANCE
61
63
  from .models import Footprint
64
+ from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
62
65
  from .render_assets import (
63
66
  EnvironmentPalette,
67
+ angle_bin_from_vector,
64
68
  build_beveled_polygon,
69
+ build_car_directional_surfaces,
65
70
  build_car_surface,
66
71
  build_flashlight_surface,
67
72
  build_fuel_can_surface,
73
+ build_player_directional_surfaces,
68
74
  build_shoes_surface,
69
- build_player_surface,
70
- build_survivor_surface,
71
- build_zombie_surface,
75
+ build_survivor_directional_surfaces,
76
+ build_zombie_directional_surfaces,
77
+ draw_humanoid_hand,
78
+ draw_humanoid_nose,
72
79
  paint_car_surface,
73
80
  paint_steel_beam_surface,
74
81
  paint_wall_surface,
75
- paint_zombie_surface,
76
82
  resolve_car_color,
77
83
  resolve_steel_beam_colors,
78
84
  resolve_wall_colors,
@@ -229,6 +235,14 @@ class SteelBeam(pygame.sprite.Sprite):
229
235
  )
230
236
 
231
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
+
232
246
  MovementStrategy = Callable[
233
247
  [
234
248
  "Zombie",
@@ -242,6 +256,8 @@ MovementStrategy = Callable[
242
256
  ],
243
257
  tuple[float, float],
244
258
  ]
259
+
260
+
245
261
  def _sprite_center_and_radius(
246
262
  sprite: pygame.sprite.Sprite,
247
263
  ) -> tuple[tuple[int, int], float]:
@@ -567,7 +583,14 @@ class Player(pygame.sprite.Sprite):
567
583
  ) -> None:
568
584
  super().__init__()
569
585
  self.radius = PLAYER_RADIUS
570
- 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]
571
594
  self.rect = self.image.get_rect(center=(x, y))
572
595
  self.speed = PLAYER_SPEED
573
596
  self.in_car = False
@@ -591,6 +614,9 @@ class Player(pygame.sprite.Sprite):
591
614
  if level_width is None or level_height is None:
592
615
  raise ValueError("level_width/level_height are required for movement")
593
616
 
617
+ inner_wall_hit = False
618
+ inner_wall_cell: tuple[int, int] | None = None
619
+
594
620
  if dx != 0:
595
621
  self.x += dx
596
622
  self.x = min(level_width, max(0, self.x))
@@ -606,6 +632,13 @@ class Player(pygame.sprite.Sprite):
606
632
  for wall in hit_list_x:
607
633
  if wall.alive():
608
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
+ )
609
642
  self.x -= dx * 1.5
610
643
  self.rect.centerx = int(self.x)
611
644
 
@@ -624,10 +657,51 @@ class Player(pygame.sprite.Sprite):
624
657
  for wall in hit_list_y:
625
658
  if wall.alive():
626
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
+ )
627
667
  self.y -= dy * 1.5
628
668
  self.rect.centery = int(self.y)
629
669
 
630
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)
631
705
 
632
706
 
633
707
  class Survivor(pygame.sprite.Sprite):
@@ -643,10 +717,16 @@ class Survivor(pygame.sprite.Sprite):
643
717
  super().__init__()
644
718
  self.is_buddy = is_buddy
645
719
  self.radius = BUDDY_RADIUS if is_buddy else SURVIVOR_RADIUS
646
- 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(
647
725
  self.radius,
648
726
  is_buddy=is_buddy,
727
+ draw_hands=is_buddy,
649
728
  )
729
+ self.image = self.directional_images[self.facing_bin]
650
730
  self.rect = self.image.get_rect(center=(int(x), int(y)))
651
731
  self.x = float(self.rect.centerx)
652
732
  self.y = float(self.rect.centery)
@@ -683,6 +763,7 @@ class Survivor(pygame.sprite.Sprite):
683
763
  grid_rows: int | None = None,
684
764
  level_width: int | None = None,
685
765
  level_height: int | None = None,
766
+ wall_target_cell: tuple[int, int] | None = None,
686
767
  ) -> None:
687
768
  if level_width is None or level_height is None:
688
769
  raise ValueError("level_width/level_height are required for movement")
@@ -691,11 +772,19 @@ class Survivor(pygame.sprite.Sprite):
691
772
  self.rect.center = (int(self.x), int(self.y))
692
773
  return
693
774
 
694
- dx = player_pos[0] - self.x
695
- 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
696
784
  dist_sq = dx * dx + dy * dy
697
785
  if dist_sq <= 0:
698
786
  self.rect.center = (int(self.x), int(self.y))
787
+ self._update_facing_for_bump(False)
699
788
  return
700
789
 
701
790
  dist = math.sqrt(dist_sq)
@@ -720,32 +809,45 @@ class Survivor(pygame.sprite.Sprite):
720
809
  grid_rows=grid_rows,
721
810
  )
722
811
 
812
+ self._update_input_facing(move_x, move_y)
813
+ inner_wall_hit = False
814
+
723
815
  if move_x:
724
816
  self.x += move_x
725
817
  self.rect.centerx = int(self.x)
726
- if spritecollideany_walls(
818
+ wall = spritecollideany_walls(
727
819
  self,
728
820
  walls,
729
821
  wall_index=wall_index,
730
822
  cell_size=cell_size,
731
- ):
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
732
829
  self.x -= move_x
733
830
  self.rect.centerx = int(self.x)
734
831
  if move_y:
735
832
  self.y += move_y
736
833
  self.rect.centery = int(self.y)
737
- if spritecollideany_walls(
834
+ wall = spritecollideany_walls(
738
835
  self,
739
836
  walls,
740
837
  wall_index=wall_index,
741
838
  cell_size=cell_size,
742
- ):
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
743
845
  self.y -= move_y
744
846
  self.rect.centery = int(self.y)
745
847
 
746
848
  overlap_radius = (self.radius + PLAYER_RADIUS) * 1.05
747
- dx_after = player_pos[0] - self.x
748
- dy_after = player_pos[1] - self.y
849
+ dx_after = target_pos[0] - self.x
850
+ dy_after = target_pos[1] - self.y
749
851
  dist_after_sq = dx_after * dx_after + dy_after * dy_after
750
852
  if 0 < dist_after_sq < overlap_radius * overlap_radius:
751
853
  dist_after = math.sqrt(dist_after_sq)
@@ -757,6 +859,7 @@ class Survivor(pygame.sprite.Sprite):
757
859
  self.x = min(level_width, max(0, self.x))
758
860
  self.y = min(level_height, max(0, self.y))
759
861
  self.rect.center = (int(self.x), int(self.y))
862
+ self._update_facing_for_bump(inner_wall_hit)
760
863
  return
761
864
 
762
865
  dx = player_pos[0] - self.x
@@ -772,6 +875,8 @@ class Survivor(pygame.sprite.Sprite):
772
875
  move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
773
876
  move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
774
877
 
878
+ self._update_input_facing(move_x, move_y)
879
+
775
880
  if (
776
881
  cell_size is not None
777
882
  and wall_cells is not None
@@ -814,6 +919,37 @@ class Survivor(pygame.sprite.Sprite):
814
919
  self.rect.centery = int(self.y)
815
920
 
816
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)
817
953
 
818
954
 
819
955
  def random_position_outside_building(
@@ -1224,13 +1360,15 @@ class Zombie(pygame.sprite.Sprite):
1224
1360
  ) -> None:
1225
1361
  super().__init__()
1226
1362
  self.radius = ZOMBIE_RADIUS
1363
+ self.facing_bin = 0
1227
1364
  self.tracker = tracker
1228
1365
  self.wall_follower = wall_follower
1229
1366
  self.carbonized = False
1230
- self.image = build_zombie_surface(
1231
- 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,
1232
1370
  )
1233
- self._redraw_image()
1371
+ self.image = self.directional_images[self.facing_bin]
1234
1372
  self.rect = self.image.get_rect(center=(x, y))
1235
1373
  jitter_base = FAST_ZOMBIE_BASE_SPEED if speed > ZOMBIE_SPEED else ZOMBIE_SPEED
1236
1374
  jitter = jitter_base * 0.2
@@ -1269,15 +1407,6 @@ class Zombie(pygame.sprite.Sprite):
1269
1407
  0, self.wander_interval_ms + RNG.randint(-500, 500)
1270
1408
  )
1271
1409
 
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
- )
1280
-
1281
1410
  def _update_mode(
1282
1411
  self: Self, player_center: tuple[int, int], sight_range: float
1283
1412
  ) -> bool:
@@ -1384,6 +1513,53 @@ class Zombie(pygame.sprite.Sprite):
1384
1513
  slowdown_ratio = 1.0 - progress * (1.0 - ZOMBIE_AGING_MIN_SPEED_RATIO)
1385
1514
  self.speed = self.initial_speed * slowdown_ratio
1386
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
+
1387
1563
  def _update_stuck_state(self: Self) -> None:
1388
1564
  history = self.pos_history
1389
1565
  history.append((self.x, self.y))
@@ -1453,18 +1629,8 @@ class Zombie(pygame.sprite.Sprite):
1453
1629
  grid_cols=grid_cols,
1454
1630
  grid_rows=grid_rows,
1455
1631
  )
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)
1632
+ self._update_facing_from_movement(move_x, move_y)
1633
+ self._apply_render_overlays()
1468
1634
  final_x, final_y = self._handle_wall_collision(
1469
1635
  self.x + move_x, self.y + move_y, walls
1470
1636
  )
@@ -1482,23 +1648,31 @@ class Zombie(pygame.sprite.Sprite):
1482
1648
  return
1483
1649
  self.carbonized = True
1484
1650
  self.speed = 0
1651
+ self.image = self.directional_images[self.facing_bin].copy()
1485
1652
  self.image.fill((0, 0, 0, 0))
1486
1653
  color = (80, 80, 80)
1487
- 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)
1488
1656
  pygame.draw.circle(
1489
- 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,
1490
1662
  )
1491
1663
 
1492
1664
 
1493
1665
  class Car(pygame.sprite.Sprite):
1494
1666
  def __init__(self: Self, x: int, y: int, *, appearance: str = "default") -> None:
1495
1667
  super().__init__()
1668
+ self.facing_bin = ANGLE_BINS * 3 // 4
1669
+ self.input_facing_bin = self.facing_bin
1496
1670
  self.original_image = build_car_surface(CAR_WIDTH, CAR_HEIGHT)
1671
+ self.directional_images: list[pygame.Surface] = []
1497
1672
  self.appearance = appearance
1498
1673
  self.image = self.original_image.copy()
1499
1674
  self.rect = self.image.get_rect(center=(x, y))
1500
1675
  self.speed = CAR_SPEED
1501
- self.angle = 0
1502
1676
  self.x = float(self.rect.centerx)
1503
1677
  self.y = float(self.rect.centery)
1504
1678
  self.health = CAR_HEALTH
@@ -1520,10 +1694,28 @@ class Car(pygame.sprite.Sprite):
1520
1694
  height=CAR_HEIGHT,
1521
1695
  color=color,
1522
1696
  )
1523
- 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]
1524
1699
  old_center = self.rect.center
1525
1700
  self.rect = self.image.get_rect(center=old_center)
1526
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
+
1527
1719
  def move(
1528
1720
  self: Self,
1529
1721
  dx: float,
@@ -1537,11 +1729,6 @@ class Car(pygame.sprite.Sprite):
1537
1729
  if dx == 0 and dy == 0:
1538
1730
  self.rect.center = (int(self.x), int(self.y))
1539
1731
  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
1732
  new_x = self.x + dx
1546
1733
  new_y = self.y + dy
1547
1734
 
@@ -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
@@ -49,8 +50,8 @@ ZOMBIE_WALL_FOLLOW_TARGET_GAP = 4.0
49
50
  ZOMBIE_WALL_FOLLOW_LOST_WALL_MS = 2500
50
51
 
51
52
  # --- Car and fuel settings ---
52
- CAR_WIDTH = 15
53
- CAR_HEIGHT = 25
53
+ CAR_WIDTH = 16
54
+ CAR_HEIGHT = 24
54
55
  CAR_SPEED = 2
55
56
  CAR_HEALTH = 20
56
57
  CAR_WALL_DAMAGE = 1
@@ -62,6 +63,7 @@ INTERNAL_WALL_HEALTH = 40 * 100
62
63
  INTERNAL_WALL_BEVEL_DEPTH = 6
63
64
  STEEL_BEAM_HEALTH = int(INTERNAL_WALL_HEALTH * 1.5)
64
65
  PLAYER_WALL_DAMAGE = 100
66
+ BUDDY_WALL_DAMAGE = int(PLAYER_WALL_DAMAGE * 0.7)
65
67
  ZOMBIE_WALL_DAMAGE = 1
66
68
 
67
69
  __all__ = [
@@ -71,6 +73,7 @@ __all__ = [
71
73
  "FOV_RADIUS",
72
74
  "BUDDY_RADIUS",
73
75
  "BUDDY_FOLLOW_SPEED",
76
+ "HUMANOID_WALL_BUMP_FRAMES",
74
77
  "SURVIVOR_RADIUS",
75
78
  "SURVIVOR_APPROACH_RADIUS",
76
79
  "SURVIVOR_APPROACH_SPEED",
@@ -110,5 +113,6 @@ __all__ = [
110
113
  "INTERNAL_WALL_BEVEL_DEPTH",
111
114
  "STEEL_BEAM_HEALTH",
112
115
  "PLAYER_WALL_DAMAGE",
116
+ "BUDDY_WALL_DAMAGE",
113
117
  "ZOMBIE_WALL_DAMAGE",
114
118
  ]
@@ -26,6 +26,10 @@ def get_shrunk_sprite(
26
26
 
27
27
  new_sprite = pygame.sprite.Sprite()
28
28
  new_sprite.rect = rect
29
+ if hasattr(sprite_obj, "radius"):
30
+ base_radius = getattr(sprite_obj, "radius", None)
31
+ if base_radius is not None:
32
+ new_sprite.radius = base_radius * min(scale_x, scale_y)
29
33
 
30
34
  return new_sprite
31
35