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/models.py CHANGED
@@ -26,19 +26,20 @@ if TYPE_CHECKING: # pragma: no cover - typing-only imports
26
26
  class LevelLayout:
27
27
  """Container for level layout rectangles and cell sets."""
28
28
 
29
- outer_rect: tuple[int, int, int, int]
30
- inner_rect: tuple[int, int, int, int]
31
- outside_rects: list[pygame.Rect]
32
- walkable_cells: list[pygame.Rect]
29
+ field_rect: pygame.Rect
30
+ outside_cells: set[tuple[int, int]]
31
+ walkable_cells: list[tuple[int, int]]
33
32
  outer_wall_cells: set[tuple[int, int]]
34
33
  wall_cells: set[tuple[int, int]]
34
+ pitfall_cells: set[tuple[int, int]]
35
+ car_walkable_cells: set[tuple[int, int]]
35
36
  fall_spawn_cells: set[tuple[int, int]]
36
37
  bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
37
38
 
38
39
 
39
40
  @dataclass
40
41
  class FallingZombie:
41
- """Represents a zombie falling toward a target position."""
42
+ """Represents a zombie falling toward a target position or into a pit."""
42
43
 
43
44
  start_pos: tuple[int, int]
44
45
  target_pos: tuple[int, int]
@@ -47,8 +48,9 @@ class FallingZombie:
47
48
  fall_duration_ms: int
48
49
  dust_duration_ms: int
49
50
  tracker: bool
50
- wall_follower: bool
51
+ wall_hugging: bool
51
52
  dust_started: bool = False
53
+ mode: str = "spawn" # "spawn" (falling in) or "pitfall" (falling out)
52
54
 
53
55
 
54
56
  @dataclass
@@ -103,9 +105,12 @@ class ProgressState:
103
105
  last_zombie_spawn_time: int
104
106
  dawn_carbonized: bool
105
107
  debug_mode: bool
108
+ show_fps: bool
106
109
  falling_zombies: list[FallingZombie]
107
110
  falling_spawn_carry: int
108
111
  dust_rings: list[DustRing]
112
+ player_wall_target_cell: tuple[int, int] | None
113
+ player_wall_target_ttl: int
109
114
 
110
115
 
111
116
  @dataclass
@@ -165,12 +170,14 @@ class Stage:
165
170
  interior_fall_spawn_weight: float = 0.0
166
171
  fall_spawn_zones: list[tuple[int, int, int, int]] = field(default_factory=list)
167
172
  fall_spawn_floor_ratio: float = 0.0
173
+ pitfall_density: float = 0.0
168
174
  zombie_tracker_ratio: float = 0.0
169
- zombie_wall_follower_ratio: float = 0.0
175
+ zombie_wall_hugging_ratio: float = 0.0
170
176
  zombie_normal_ratio: float = 1.0
171
177
  zombie_aging_duration_frames: int = ZOMBIE_AGING_DURATION_FRAMES
172
178
  waiting_car_target_count: int = 1
173
179
  wall_algorithm: str = "default"
180
+ wall_rubble_ratio: float = 0.0
174
181
 
175
182
  @property
176
183
  def name(self) -> str:
zombie_escape/render.py CHANGED
@@ -34,6 +34,8 @@ from .entities_constants import (
34
34
  FLASHLIGHT_WIDTH,
35
35
  FUEL_CAN_HEIGHT,
36
36
  FUEL_CAN_WIDTH,
37
+ INTERNAL_WALL_BEVEL_DEPTH,
38
+ JUMP_SHADOW_OFFSET,
37
39
  SHOES_HEIGHT,
38
40
  SHOES_WIDTH,
39
41
  ZOMBIE_RADIUS,
@@ -63,6 +65,11 @@ from .render_constants import (
63
65
  FALLING_ZOMBIE_COLOR,
64
66
  FLASHLIGHT_FOG_SCALE_ONE,
65
67
  FLASHLIGHT_FOG_SCALE_TWO,
68
+ PITFALL_ABYSS_COLOR,
69
+ PITFALL_EDGE_DEPTH_OFFSET,
70
+ PITFALL_EDGE_METAL_COLOR,
71
+ PITFALL_EDGE_STRIPE_COLOR,
72
+ PITFALL_EDGE_STRIPE_SPACING,
66
73
  PLAYER_SHADOW_ALPHA_MULT,
67
74
  PLAYER_SHADOW_RADIUS_MULT,
68
75
  SHADOW_MIN_RATIO,
@@ -314,9 +321,7 @@ def draw_level_overview(
314
321
  if flashlights:
315
322
  for flashlight in flashlights:
316
323
  if flashlight.alive():
317
- pygame.draw.rect(
318
- surface, YELLOW, flashlight.rect, border_radius=2
319
- )
324
+ pygame.draw.rect(surface, YELLOW, flashlight.rect, border_radius=2)
320
325
  pygame.draw.rect(
321
326
  surface, BLACK, flashlight.rect, width=2, border_radius=2
322
327
  )
@@ -587,6 +592,8 @@ def _draw_fall_whirlwind(
587
592
  camera: Camera,
588
593
  center: tuple[int, int],
589
594
  progress: float,
595
+ *,
596
+ scale: float = 1.0,
590
597
  ) -> None:
591
598
  base_alpha = FALLING_WHIRLWIND_COLOR[3]
592
599
  alpha = int(max(0, min(255, base_alpha * (1.0 - progress))))
@@ -598,8 +605,9 @@ def _draw_fall_whirlwind(
598
605
  FALLING_WHIRLWIND_COLOR[2],
599
606
  alpha,
600
607
  )
601
- swirl_radius = max(2, int(ZOMBIE_RADIUS * 1.1))
602
- offset = max(1, int(ZOMBIE_RADIUS * 0.6))
608
+ safe_scale = max(0.4, scale)
609
+ swirl_radius = max(2, int(ZOMBIE_RADIUS * 1.1 * safe_scale))
610
+ offset = max(1, int(ZOMBIE_RADIUS * 0.6 * safe_scale))
603
611
  size = swirl_radius * 4
604
612
  swirl = pygame.Surface((size, size), pygame.SRCALPHA)
605
613
  cx = cy = size // 2
@@ -632,22 +640,42 @@ def _draw_falling_fx(
632
640
  if now < fall_start:
633
641
  if flashlight_count > 0 and pre_fx_ms > 0:
634
642
  fx_progress = max(0.0, min(1.0, (now - fall.started_at_ms) / pre_fx_ms))
635
- _draw_fall_whirlwind(screen, camera, fall.target_pos, fx_progress)
643
+ # Make the premonition grow with the impending drop scale.
644
+ pre_scale = 1.0 + (0.9 * fx_progress)
645
+ _draw_fall_whirlwind(
646
+ screen,
647
+ camera,
648
+ fall.start_pos,
649
+ fx_progress,
650
+ scale=pre_scale,
651
+ )
636
652
  continue
637
653
  if now >= impact_at:
638
654
  continue
639
655
  fall_progress = max(0.0, min(1.0, (now - fall_start) / fall_duration_ms))
640
- eased = 1.0 - (1.0 - fall_progress) * (1.0 - fall_progress)
641
- x = fall.target_pos[0]
642
- y = int(fall.start_pos[1] + (fall.target_pos[1] - fall.start_pos[1]) * eased)
643
- world_rect = pygame.Rect(0, 0, ZOMBIE_RADIUS * 2, ZOMBIE_RADIUS * 2)
644
- world_rect.center = (x, y)
656
+
657
+ if getattr(fall, "mode", "spawn") == "pitfall":
658
+ scale = 1.0 - fall_progress
659
+ scale = scale * scale
660
+ y_offset = 0.0
661
+ else:
662
+ eased = 1.0 - (1.0 - fall_progress) * (1.0 - fall_progress)
663
+ scale = 2.0 - (1.0 * eased)
664
+ # Add an extra vertical drop from above (1.5x wall depth)
665
+ y_offset = -INTERNAL_WALL_BEVEL_DEPTH * 1.5 * (1.0 - eased)
666
+
667
+ radius = ZOMBIE_RADIUS * scale
668
+ cx = fall.target_pos[0]
669
+ cy = fall.target_pos[1] + ZOMBIE_RADIUS - radius + y_offset
670
+
671
+ world_rect = pygame.Rect(0, 0, radius * 2, radius * 2)
672
+ world_rect.center = (int(cx), int(cy))
645
673
  screen_rect = camera.apply_rect(world_rect)
646
674
  pygame.draw.circle(
647
675
  screen,
648
676
  FALLING_ZOMBIE_COLOR,
649
677
  screen_rect.center,
650
- ZOMBIE_RADIUS,
678
+ max(1, int(screen_rect.width / 2)),
651
679
  )
652
680
 
653
681
  for ring in list(dust_rings):
@@ -725,6 +753,7 @@ def _draw_status_bar(
725
753
  debug_mode: bool = False,
726
754
  zombie_group: sprite.Group | None = None,
727
755
  falling_spawn_carry: int | None = None,
756
+ show_fps: bool = False,
728
757
  fps: float | None = None,
729
758
  ) -> None:
730
759
  """Render a compact status bar with current config flags and stage info."""
@@ -766,14 +795,12 @@ def _draw_status_bar(
766
795
  zombies = [z for z in zombie_group if z.alive()]
767
796
  total = len(zombies)
768
797
  tracker = sum(1 for z in zombies if z.tracker)
769
- wall = sum(1 for z in zombies if z.wall_follower)
798
+ wall = sum(1 for z in zombies if z.wall_hugging)
770
799
  normal = max(0, total - tracker - wall)
771
- parts.append(f"Z:{total} N:{normal} T:{tracker} W:{wall}")
800
+ debug_counts = f"Z:{total} N:{normal} T:{tracker} W:{wall}"
772
801
  if falling_spawn_carry is not None:
773
- parts.append(f"C:{max(0, falling_spawn_carry)}")
774
- if fps is not None:
775
- parts.append(f"FPS:{fps:.1f}")
776
-
802
+ debug_counts = f"{debug_counts} C:{max(0, falling_spawn_carry)}"
803
+ parts.append(debug_counts)
777
804
  status_text = " | ".join(parts)
778
805
  color = LIGHT_GRAY
779
806
 
@@ -790,7 +817,7 @@ def _draw_status_bar(
790
817
  right=bar_rect.right - 12, centery=bar_rect.centery
791
818
  )
792
819
  screen.blit(seed_surface, seed_rect)
793
- if debug_mode and fps is not None:
820
+ if show_fps and fps is not None:
794
821
  fps_text = f"FPS:{fps:.1f}"
795
822
  fps_surface = font.render(fps_text, False, LIGHT_GRAY)
796
823
  fps_rect = fps_surface.get_rect(
@@ -807,11 +834,17 @@ def _draw_play_area(
807
834
  camera: Camera,
808
835
  assets: RenderAssets,
809
836
  palette: Any,
810
- outer_rect: tuple[int, int, int, int],
811
- outside_rects: list[pygame.Rect],
837
+ field_rect: pygame.Rect,
838
+ outside_cells: set[tuple[int, int]],
812
839
  fall_spawn_cells: set[tuple[int, int]],
840
+ pitfall_cells: set[tuple[int, int]],
813
841
  ) -> tuple[int, int, int, int, set[tuple[int, int]]]:
814
- xs, ys, xe, ye = outer_rect
842
+ xs, ys, xe, ye = (
843
+ field_rect.left,
844
+ field_rect.top,
845
+ field_rect.right,
846
+ field_rect.bottom,
847
+ )
815
848
  xs //= assets.internal_wall_grid_snap
816
849
  ys //= assets.internal_wall_grid_snap
817
850
  xe //= assets.internal_wall_grid_snap
@@ -826,15 +859,6 @@ def _draw_play_area(
826
859
  play_area_screen_rect = camera.apply_rect(play_area_rect)
827
860
  pygame.draw.rect(screen, palette.floor_primary, play_area_screen_rect)
828
861
 
829
- outside_cells = {
830
- (r.x // assets.internal_wall_grid_snap, r.y // assets.internal_wall_grid_snap)
831
- for r in outside_rects
832
- }
833
- for rect_obj in outside_rects:
834
- sr = camera.apply_rect(rect_obj)
835
- if sr.colliderect(screen.get_rect()):
836
- pygame.draw.rect(screen, palette.outside, sr)
837
-
838
862
  view_world = pygame.Rect(
839
863
  -camera.camera.x,
840
864
  -camera.camera.y,
@@ -855,7 +879,57 @@ def _draw_play_area(
855
879
  for y in range(start_y, end_y):
856
880
  for x in range(start_x, end_x):
857
881
  if (x, y) in outside_cells:
882
+ lx, ly = (
883
+ x * assets.internal_wall_grid_snap,
884
+ y * assets.internal_wall_grid_snap,
885
+ )
886
+ r = pygame.Rect(
887
+ lx,
888
+ ly,
889
+ assets.internal_wall_grid_snap,
890
+ assets.internal_wall_grid_snap,
891
+ )
892
+ sr = camera.apply_rect(r)
893
+ if sr.colliderect(screen.get_rect()):
894
+ pygame.draw.rect(screen, palette.outside, sr)
895
+ continue
896
+
897
+ if (x, y) in pitfall_cells:
898
+ lx, ly = (
899
+ x * assets.internal_wall_grid_snap,
900
+ y * assets.internal_wall_grid_snap,
901
+ )
902
+ r = pygame.Rect(
903
+ lx,
904
+ ly,
905
+ assets.internal_wall_grid_snap,
906
+ assets.internal_wall_grid_snap,
907
+ )
908
+ sr = camera.apply_rect(r)
909
+ if not sr.colliderect(screen.get_rect()):
910
+ continue
911
+ pygame.draw.rect(screen, PITFALL_ABYSS_COLOR, sr)
912
+
913
+ if (x, y - 1) not in pitfall_cells:
914
+ edge_h = max(
915
+ 1, INTERNAL_WALL_BEVEL_DEPTH - PITFALL_EDGE_DEPTH_OFFSET
916
+ )
917
+ pygame.draw.rect(
918
+ screen, PITFALL_EDGE_METAL_COLOR, (sr.x, sr.y, sr.w, edge_h)
919
+ )
920
+ for sx in range(
921
+ sr.x - edge_h, sr.right, PITFALL_EDGE_STRIPE_SPACING
922
+ ):
923
+ pygame.draw.line(
924
+ screen,
925
+ PITFALL_EDGE_STRIPE_COLOR,
926
+ (max(sr.x, sx), sr.y),
927
+ (min(sr.right - 1, sx + edge_h), sr.y + edge_h - 1),
928
+ width=2,
929
+ )
930
+
858
931
  continue
932
+
859
933
  use_secondary = ((x // 2) + (y // 2)) % 2 == 0
860
934
  if (x, y) in fall_spawn_cells:
861
935
  color = (
@@ -966,11 +1040,15 @@ def _draw_entity_shadows(
966
1040
  *,
967
1041
  light_source_pos: tuple[int, int] | None,
968
1042
  exclude_car: Car | None,
1043
+ outside_cells: set[tuple[int, int]] | None,
1044
+ cell_size: int,
969
1045
  shadow_radius: int = int(ZOMBIE_RADIUS * ENTITY_SHADOW_RADIUS_MULT),
970
1046
  alpha: int = ENTITY_SHADOW_ALPHA,
971
1047
  ) -> bool:
972
1048
  if light_source_pos is None or shadow_radius <= 0:
973
1049
  return False
1050
+ if cell_size <= 0:
1051
+ outside_cells = None
974
1052
  shadow_surface = _get_shadow_circle_surface(
975
1053
  shadow_radius,
976
1054
  alpha,
@@ -990,6 +1068,13 @@ def _draw_entity_shadows(
990
1068
  continue
991
1069
  if not isinstance(entity, (Zombie, Survivor, Car)):
992
1070
  continue
1071
+ if outside_cells:
1072
+ cell = (
1073
+ int(entity.rect.centerx // cell_size),
1074
+ int(entity.rect.centery // cell_size),
1075
+ )
1076
+ if cell in outside_cells:
1077
+ continue
993
1078
  cx, cy = entity.rect.center
994
1079
  dx = cx - px
995
1080
  dy = cy - py
@@ -1001,8 +1086,13 @@ def _draw_entity_shadows(
1001
1086
  else:
1002
1087
  offset_x = 0.0
1003
1088
  offset_y = 0.0
1089
+
1090
+ jump_dy = 0.0
1091
+ if getattr(entity, "is_jumping", False):
1092
+ jump_dy = JUMP_SHADOW_OFFSET
1093
+
1004
1094
  shadow_rect = shadow_surface.get_rect(
1005
- center=(int(cx + offset_x), int(cy + offset_y))
1095
+ center=(int(cx + offset_x), int(cy + offset_y + jump_dy))
1006
1096
  )
1007
1097
  shadow_screen_rect = camera.apply_rect(shadow_rect)
1008
1098
  if not shadow_screen_rect.colliderect(screen_rect):
@@ -1022,6 +1112,8 @@ def _draw_single_entity_shadow(
1022
1112
  *,
1023
1113
  entity: pygame.sprite.Sprite | None,
1024
1114
  light_source_pos: tuple[int, int] | None,
1115
+ outside_cells: set[tuple[int, int]] | None,
1116
+ cell_size: int,
1025
1117
  shadow_radius: int,
1026
1118
  alpha: int,
1027
1119
  edge_softness: float = ENTITY_SHADOW_EDGE_SOFTNESS,
@@ -1033,6 +1125,13 @@ def _draw_single_entity_shadow(
1033
1125
  or shadow_radius <= 0
1034
1126
  ):
1035
1127
  return False
1128
+ if outside_cells and cell_size > 0:
1129
+ cell = (
1130
+ int(entity.rect.centerx // cell_size),
1131
+ int(entity.rect.centery // cell_size),
1132
+ )
1133
+ if cell in outside_cells:
1134
+ return False
1036
1135
  shadow_surface = _get_shadow_circle_surface(
1037
1136
  shadow_radius,
1038
1137
  alpha,
@@ -1052,8 +1151,13 @@ def _draw_single_entity_shadow(
1052
1151
  else:
1053
1152
  offset_x = 0.0
1054
1153
  offset_y = 0.0
1154
+
1155
+ jump_dy = 0.0
1156
+ if getattr(entity, "is_jumping", False):
1157
+ jump_dy = JUMP_SHADOW_OFFSET
1158
+
1055
1159
  shadow_rect = shadow_surface.get_rect(
1056
- center=(int(cx + offset_x), int(cy + offset_y))
1160
+ center=(int(cx + offset_x), int(cy + offset_y + jump_dy))
1057
1161
  )
1058
1162
  shadow_screen_rect = camera.apply_rect(shadow_rect)
1059
1163
  if not shadow_screen_rect.colliderect(screen_rect):
@@ -1464,8 +1568,8 @@ def draw(
1464
1568
 
1465
1569
  camera = game_data.camera
1466
1570
  stage = game_data.stage
1467
- outer_rect = game_data.layout.outer_rect
1468
- outside_rects = game_data.layout.outside_rects or []
1571
+ field_rect = game_data.layout.field_rect
1572
+ outside_cells = game_data.layout.outside_cells
1469
1573
  all_sprites = game_data.groups.all_sprites
1470
1574
  fog_surfaces = game_data.fog
1471
1575
  footprints = state.footprints
@@ -1493,9 +1597,10 @@ def draw(
1493
1597
  camera,
1494
1598
  assets,
1495
1599
  palette,
1496
- outer_rect,
1497
- outside_rects,
1600
+ field_rect,
1601
+ outside_cells,
1498
1602
  game_data.layout.fall_spawn_cells,
1603
+ game_data.layout.pitfall_cells,
1499
1604
  )
1500
1605
  shadow_layer = _get_shadow_layer(screen.get_size())
1501
1606
  shadow_layer.fill((0, 0, 0, 0))
@@ -1520,6 +1625,8 @@ def draw(
1520
1625
  all_sprites,
1521
1626
  light_source_pos=fov_target.rect.center if fov_target else None,
1522
1627
  exclude_car=active_car if player.in_car else None,
1628
+ outside_cells=outside_cells,
1629
+ cell_size=game_data.cell_size,
1523
1630
  )
1524
1631
  player_shadow_alpha = max(1, int(ENTITY_SHADOW_ALPHA * PLAYER_SHADOW_ALPHA_MULT))
1525
1632
  player_shadow_radius = int(ZOMBIE_RADIUS * PLAYER_SHADOW_RADIUS_MULT)
@@ -1529,6 +1636,8 @@ def draw(
1529
1636
  camera,
1530
1637
  entity=active_car,
1531
1638
  light_source_pos=fov_target.rect.center if fov_target else None,
1639
+ outside_cells=outside_cells,
1640
+ cell_size=game_data.cell_size,
1532
1641
  shadow_radius=player_shadow_radius,
1533
1642
  alpha=player_shadow_alpha,
1534
1643
  )
@@ -1538,6 +1647,8 @@ def draw(
1538
1647
  camera,
1539
1648
  entity=player,
1540
1649
  light_source_pos=fov_target.rect.center if fov_target else None,
1650
+ outside_cells=outside_cells,
1651
+ cell_size=game_data.cell_size,
1541
1652
  shadow_radius=player_shadow_radius,
1542
1653
  alpha=player_shadow_alpha,
1543
1654
  )
@@ -1625,6 +1736,7 @@ def draw(
1625
1736
  debug_mode=state.debug_mode,
1626
1737
  zombie_group=zombie_group,
1627
1738
  falling_spawn_carry=state.falling_spawn_carry,
1739
+ show_fps=state.show_fps,
1628
1740
  fps=fps,
1629
1741
  )
1630
1742
  if do_flip: