zombie-escape 1.7.1__tar.gz → 1.8.0__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 (46) hide show
  1. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/PKG-INFO +4 -3
  2. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/README.md +3 -2
  3. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/__about__.py +1 -1
  4. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/entities.py +172 -65
  5. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/constants.py +0 -6
  6. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/layout.py +5 -0
  7. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/movement.py +43 -3
  8. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/spawn.py +38 -31
  9. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/state.py +2 -0
  10. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/survivors.py +46 -15
  11. zombie_escape-1.8.0/src/zombie_escape/input_utils.py +167 -0
  12. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/level_blueprints.py +28 -0
  13. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/locales/ui.en.json +41 -9
  14. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/locales/ui.ja.json +40 -8
  15. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/localization.py +28 -0
  16. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/models.py +2 -0
  17. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/screens/game_over.py +4 -0
  18. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/screens/gameplay.py +78 -17
  19. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/screens/settings.py +124 -13
  20. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/screens/title.py +111 -0
  21. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/stage_constants.py +26 -1
  22. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/zombie_escape.py +3 -0
  23. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/.gitignore +0 -0
  24. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/LICENSE.txt +0 -0
  25. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/pyproject.toml +0 -0
  26. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/__init__.py +0 -0
  27. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/assets/fonts/Silkscreen-Regular.ttf +0 -0
  28. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/assets/fonts/misaki_gothic.ttf +0 -0
  29. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/colors.py +0 -0
  30. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/config.py +0 -0
  31. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/entities_constants.py +0 -0
  32. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/font_utils.py +0 -0
  33. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/__init__.py +0 -0
  34. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/ambient.py +0 -0
  35. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/footprints.py +0 -0
  36. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/interactions.py +0 -0
  37. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay/utils.py +0 -0
  38. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/gameplay_constants.py +0 -0
  39. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/level_constants.py +0 -0
  40. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/progress.py +0 -0
  41. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/render.py +0 -0
  42. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/render_assets.py +0 -0
  43. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/render_constants.py +0 -0
  44. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/rng.py +0 -0
  45. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/screen_constants.py +0 -0
  46. {zombie_escape-1.7.1 → zombie_escape-1.8.0}/src/zombie_escape/screens/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zombie-escape
3
- Version: 1.7.1
3
+ Version: 1.8.0
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>
@@ -46,10 +46,11 @@ This game is a simple 2D top-down action game where the player aims to escape by
46
46
 
47
47
  - **Player/Car Movement:** `W` / `↑` (Up), `A` / `←` (Left), `S` / `↓` (Down), `D` / `→` (Right)
48
48
  - **Enter Car:** Overlap the player with the car.
49
- - **Quit Game:** `ESC` key
49
+ - **Pause:** `P`/Start or `ESC`/Select
50
+ - **Quit Game:** `ESC`/Select (from pause)
50
51
  - **Restart:** `R` key (on Game Over/Clear screen)
51
52
  - **Window/Fullscreen (title/settings only):** `[` to shrink, `]` to enlarge, `F` to toggle fullscreen
52
- - **Time Acceleration:** Hold either `Shift` key to run the entire world 4x faster; release to return to normal speed.
53
+ - **Time Acceleration:** Hold either `Shift` key or `R1` to run the entire world 4x faster; release to return to normal speed.
53
54
 
54
55
  ## Title Screen
55
56
 
@@ -24,10 +24,11 @@ This game is a simple 2D top-down action game where the player aims to escape by
24
24
 
25
25
  - **Player/Car Movement:** `W` / `↑` (Up), `A` / `←` (Left), `S` / `↓` (Down), `D` / `→` (Right)
26
26
  - **Enter Car:** Overlap the player with the car.
27
- - **Quit Game:** `ESC` key
27
+ - **Pause:** `P`/Start or `ESC`/Select
28
+ - **Quit Game:** `ESC`/Select (from pause)
28
29
  - **Restart:** `R` key (on Game Over/Clear screen)
29
30
  - **Window/Fullscreen (title/settings only):** `[` to shrink, `]` to enlarge, `F` to toggle fullscreen
30
- - **Time Acceleration:** Hold either `Shift` key to run the entire world 4x faster; release to return to normal speed.
31
+ - **Time Acceleration:** Hold either `Shift` key or `R1` to run the entire world 4x faster; release to return to normal speed.
31
32
 
32
33
  ## Title Screen
33
34
 
@@ -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.7.1"
4
+ __version__ = "1.8.0"
@@ -131,6 +131,86 @@ def walls_for_radius(
131
131
  return candidates
132
132
 
133
133
 
134
+ def apply_tile_edge_nudge(
135
+ x: float,
136
+ y: float,
137
+ dx: float,
138
+ dy: float,
139
+ *,
140
+ cell_size: int,
141
+ wall_cells: set[tuple[int, int]] | None,
142
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] | None = None,
143
+ grid_cols: int,
144
+ grid_rows: int,
145
+ strength: float = 0.03,
146
+ edge_margin_ratio: float = 0.15,
147
+ min_margin: float = 2.0,
148
+ ) -> tuple[float, float]:
149
+ if dx == 0 and dy == 0:
150
+ return dx, dy
151
+ if cell_size <= 0 or not wall_cells:
152
+ return dx, dy
153
+ cell_x = int(x // cell_size)
154
+ cell_y = int(y // cell_size)
155
+ if cell_x < 0 or cell_y < 0 or cell_x >= grid_cols or cell_y >= grid_rows:
156
+ return dx, dy
157
+ speed = math.hypot(dx, dy)
158
+ if speed <= 0:
159
+ return dx, dy
160
+
161
+ edge_margin = max(min_margin, cell_size * edge_margin_ratio)
162
+ left_dist = x - (cell_x * cell_size)
163
+ right_dist = ((cell_x + 1) * cell_size) - x
164
+ top_dist = y - (cell_y * cell_size)
165
+ bottom_dist = ((cell_y + 1) * cell_size) - y
166
+
167
+ def apply_push(dist: float, direction: float) -> float:
168
+ if dist >= edge_margin:
169
+ return 0.0
170
+ ratio = (edge_margin - dist) / edge_margin
171
+ return ratio * speed * strength * direction
172
+
173
+ if (cell_x - 1, cell_y) in wall_cells:
174
+ dx += apply_push(left_dist, 1.0)
175
+ if (cell_x + 1, cell_y) in wall_cells:
176
+ dx += apply_push(right_dist, -1.0)
177
+ if (cell_x, cell_y - 1) in wall_cells:
178
+ dy += apply_push(top_dist, 1.0)
179
+ if (cell_x, cell_y + 1) in wall_cells:
180
+ dy += apply_push(bottom_dist, -1.0)
181
+
182
+ def apply_corner_push(dist_a: float, dist_b: float, boost: float = 1.0) -> float:
183
+ if dist_a >= edge_margin or dist_b >= edge_margin:
184
+ return 0.0
185
+ ratio = (edge_margin - min(dist_a, dist_b)) / edge_margin
186
+ return ratio * speed * strength * boost
187
+
188
+ if bevel_corners:
189
+ boosted = 1.25
190
+ corner_wall = bevel_corners.get((cell_x - 1, cell_y - 1))
191
+ if corner_wall and corner_wall[2]:
192
+ push = apply_corner_push(left_dist, top_dist, boosted)
193
+ dx += push
194
+ dy += push
195
+ corner_wall = bevel_corners.get((cell_x + 1, cell_y - 1))
196
+ if corner_wall and corner_wall[3]:
197
+ push = apply_corner_push(right_dist, top_dist, boosted)
198
+ dx -= push
199
+ dy += push
200
+ corner_wall = bevel_corners.get((cell_x + 1, cell_y + 1))
201
+ if corner_wall and corner_wall[0]:
202
+ push = apply_corner_push(right_dist, bottom_dist, boosted)
203
+ dx -= push
204
+ dy -= push
205
+ corner_wall = bevel_corners.get((cell_x - 1, cell_y + 1))
206
+ if corner_wall and corner_wall[1]:
207
+ push = apply_corner_push(left_dist, bottom_dist, boosted)
208
+ dx += push
209
+ dy -= push
210
+
211
+ return dx, dy
212
+
213
+
134
214
  def _walls_for_sprite(
135
215
  sprite: pygame.sprite.Sprite,
136
216
  wall_index: WallIndex,
@@ -471,7 +551,9 @@ class Wall(pygame.sprite.Sprite):
471
551
  return self.rect.colliderect(rect_obj)
472
552
  return rect_polygon_collision(rect_obj, self._collision_polygon)
473
553
 
474
- def _collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
554
+ def _collides_circle(
555
+ self: Self, center: tuple[float, float], radius: float
556
+ ) -> bool:
475
557
  if not _circle_rect_collision(center, radius, self.rect):
476
558
  return False
477
559
  if self._collision_polygon is None:
@@ -673,6 +755,11 @@ class Survivor(pygame.sprite.Sprite):
673
755
  *,
674
756
  wall_index: WallIndex | None = None,
675
757
  cell_size: int | None = None,
758
+ wall_cells: set[tuple[int, int]] | None = None,
759
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
760
+ | None = None,
761
+ grid_cols: int | None = None,
762
+ grid_rows: int | None = None,
676
763
  level_width: int | None = None,
677
764
  level_height: int | None = None,
678
765
  ) -> None:
@@ -694,6 +781,24 @@ class Survivor(pygame.sprite.Sprite):
694
781
  move_x = (dx / dist) * BUDDY_FOLLOW_SPEED
695
782
  move_y = (dy / dist) * BUDDY_FOLLOW_SPEED
696
783
 
784
+ if (
785
+ cell_size is not None
786
+ and wall_cells is not None
787
+ and grid_cols is not None
788
+ and grid_rows is not None
789
+ ):
790
+ move_x, move_y = apply_tile_edge_nudge(
791
+ self.x,
792
+ self.y,
793
+ move_x,
794
+ move_y,
795
+ cell_size=cell_size,
796
+ wall_cells=wall_cells,
797
+ bevel_corners=bevel_corners,
798
+ grid_cols=grid_cols,
799
+ grid_rows=grid_rows,
800
+ )
801
+
697
802
  if move_x:
698
803
  self.x += move_x
699
804
  self.rect.centerx = int(self.x)
@@ -746,6 +851,24 @@ class Survivor(pygame.sprite.Sprite):
746
851
  move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
747
852
  move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
748
853
 
854
+ if (
855
+ cell_size is not None
856
+ and wall_cells is not None
857
+ and grid_cols is not None
858
+ and grid_rows is not None
859
+ ):
860
+ move_x, move_y = apply_tile_edge_nudge(
861
+ self.x,
862
+ self.y,
863
+ move_x,
864
+ move_y,
865
+ cell_size=cell_size,
866
+ wall_cells=wall_cells,
867
+ bevel_corners=bevel_corners,
868
+ grid_cols=grid_cols,
869
+ grid_rows=grid_rows,
870
+ )
871
+
749
872
  if move_x:
750
873
  self.x += move_x
751
874
  self.rect.centerx = int(self.x)
@@ -1068,19 +1191,17 @@ def _zombie_wander_move(
1068
1191
  if at_x_edge or at_y_edge:
1069
1192
  if outer_wall_cells is not None:
1070
1193
  if at_x_edge:
1071
- inward_cell = (
1072
- (1, cell_y) if cell_x == 0 else (grid_cols - 2, cell_y)
1073
- )
1194
+ inward_cell = (1, cell_y) if cell_x == 0 else (grid_cols - 2, cell_y)
1074
1195
  if inward_cell not in outer_wall_cells:
1075
- inward_dx = zombie.speed if cell_x == 0 else -zombie.speed
1076
- return inward_dx, 0.0
1196
+ target_x = (inward_cell[0] + 0.5) * cell_size
1197
+ target_y = (inward_cell[1] + 0.5) * cell_size
1198
+ return zombie_move_toward(zombie, (target_x, target_y))
1077
1199
  if at_y_edge:
1078
- inward_cell = (
1079
- (cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
1080
- )
1200
+ inward_cell = (cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
1081
1201
  if inward_cell not in outer_wall_cells:
1082
- inward_dy = zombie.speed if cell_y == 0 else -zombie.speed
1083
- return 0.0, inward_dy
1202
+ target_x = (inward_cell[0] + 0.5) * cell_size
1203
+ target_y = (inward_cell[1] + 0.5) * cell_size
1204
+ return zombie_move_toward(zombie, (target_x, target_y))
1084
1205
  else:
1085
1206
 
1086
1207
  def path_clear(next_x: float, next_y: float) -> bool:
@@ -1103,12 +1224,12 @@ def _zombie_wander_move(
1103
1224
  inward_dy = zombie.speed if cell_y == 0 else -zombie.speed
1104
1225
  if path_clear(zombie.x, zombie.y + inward_dy):
1105
1226
  return 0.0, inward_dy
1106
- if at_x_edge:
1107
- direction = 1.0 if math.sin(zombie.wander_angle) >= 0 else -1.0
1108
- return 0.0, direction * zombie.speed
1109
- if at_y_edge:
1110
- direction = 1.0 if math.cos(zombie.wander_angle) >= 0 else -1.0
1111
- return direction * zombie.speed, 0.0
1227
+ # if at_x_edge:
1228
+ # direction = 1.0 if math.sin(zombie.wander_angle) >= 0 else -1.0
1229
+ # return 0.0, direction * zombie.speed
1230
+ # if at_y_edge:
1231
+ # direction = 1.0 if math.cos(zombie.wander_angle) >= 0 else -1.0
1232
+ # return direction * zombie.speed, 0.0
1112
1233
 
1113
1234
  move_x = math.cos(zombie.wander_angle) * zombie.speed
1114
1235
  move_y = math.sin(zombie.wander_angle) * zombie.speed
@@ -1230,47 +1351,8 @@ class Zombie(pygame.sprite.Sprite):
1230
1351
  final_y = self.y
1231
1352
  break
1232
1353
 
1233
- for wall in possible_walls:
1234
- final_x, final_y = self._apply_bevel_corner_repulsion(
1235
- final_x, final_y, wall
1236
- )
1237
-
1238
1354
  return final_x, final_y
1239
1355
 
1240
- def _apply_bevel_corner_repulsion(
1241
- self: Self, x: float, y: float, wall: Wall
1242
- ) -> tuple[float, float]:
1243
- bevel_depth = int(getattr(wall, "bevel_depth", 0) or 0)
1244
- bevel_mask = getattr(wall, "bevel_mask", None)
1245
- if bevel_depth <= 0 or not bevel_mask or not any(bevel_mask):
1246
- return x, y
1247
-
1248
- influence = self.radius + bevel_depth
1249
- repel_ratio = 0.03
1250
- corners = (
1251
- (bevel_mask[0], wall.rect.left, wall.rect.top, -1.0, -1.0), # tl
1252
- (bevel_mask[1], wall.rect.right, wall.rect.top, 1.0, -1.0), # tr
1253
- (bevel_mask[2], wall.rect.right, wall.rect.bottom, 1.0, 1.0), # br
1254
- (bevel_mask[3], wall.rect.left, wall.rect.bottom, -1.0, 1.0), # bl
1255
- )
1256
- for enabled, corner_x, corner_y, dir_x, dir_y in corners:
1257
- if not enabled:
1258
- continue
1259
- dx = x - corner_x
1260
- dy = y - corner_y
1261
- if abs(dx) > influence or abs(dy) > influence:
1262
- continue
1263
- dist = math.hypot(dx, dy)
1264
- if dist >= influence:
1265
- continue
1266
- push = (influence - dist) * repel_ratio
1267
- if push <= 0:
1268
- continue
1269
- x += dir_x * push
1270
- y += dir_y * push
1271
-
1272
- return x, y
1273
-
1274
1356
  def _avoid_other_zombies(
1275
1357
  self: Self,
1276
1358
  move_x: float,
@@ -1368,6 +1450,9 @@ class Zombie(pygame.sprite.Sprite):
1368
1450
  level_width: int,
1369
1451
  level_height: int,
1370
1452
  outer_wall_cells: set[tuple[int, int]] | None = None,
1453
+ wall_cells: set[tuple[int, int]] | None = None,
1454
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
1455
+ | None = None,
1371
1456
  ) -> None:
1372
1457
  if self.carbonized:
1373
1458
  return
@@ -1390,6 +1475,18 @@ class Zombie(pygame.sprite.Sprite):
1390
1475
  )
1391
1476
  if dist_to_player_sq <= avoid_radius_sq or self.wall_follower:
1392
1477
  move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
1478
+ if wall_cells is not None:
1479
+ move_x, move_y = apply_tile_edge_nudge(
1480
+ self.x,
1481
+ self.y,
1482
+ move_x,
1483
+ move_y,
1484
+ cell_size=cell_size,
1485
+ wall_cells=wall_cells,
1486
+ bevel_corners=bevel_corners,
1487
+ grid_cols=grid_cols,
1488
+ grid_rows=grid_rows,
1489
+ )
1393
1490
  if self.wall_follower and self.wall_follow_side != 0:
1394
1491
  if move_x != 0 or move_y != 0:
1395
1492
  heading = math.atan2(move_y, move_x)
@@ -1407,9 +1504,8 @@ class Zombie(pygame.sprite.Sprite):
1407
1504
  )
1408
1505
 
1409
1506
  if not (0 <= final_x < level_width and 0 <= final_y < level_height):
1410
- final_x, final_y = random_position_outside_building(
1411
- level_width, level_height
1412
- )
1507
+ self.kill()
1508
+ return
1413
1509
 
1414
1510
  self.x = final_x
1415
1511
  self.y = final_y
@@ -1462,7 +1558,14 @@ class Car(pygame.sprite.Sprite):
1462
1558
  old_center = self.rect.center
1463
1559
  self.rect = self.image.get_rect(center=old_center)
1464
1560
 
1465
- def move(self: Self, dx: float, dy: float, walls: Iterable[Wall]) -> None:
1561
+ def move(
1562
+ self: Self,
1563
+ dx: float,
1564
+ dy: float,
1565
+ walls: Iterable[Wall],
1566
+ *,
1567
+ walls_nearby: bool = False,
1568
+ ) -> None:
1466
1569
  if self.health <= 0:
1467
1570
  return
1468
1571
  if dx == 0 and dy == 0:
@@ -1477,11 +1580,15 @@ class Car(pygame.sprite.Sprite):
1477
1580
  new_y = self.y + dy
1478
1581
 
1479
1582
  hit_walls = []
1480
- possible_walls = [
1481
- w
1482
- for w in walls
1483
- if abs(w.rect.centery - self.y) < 100 and abs(w.rect.centerx - new_x) < 100
1484
- ]
1583
+ if walls_nearby:
1584
+ possible_walls = list(walls)
1585
+ else:
1586
+ possible_walls = [
1587
+ w
1588
+ for w in walls
1589
+ if abs(w.rect.centery - self.y) < 100
1590
+ and abs(w.rect.centerx - new_x) < 100
1591
+ ]
1485
1592
  car_center = (new_x, new_y)
1486
1593
  for wall in possible_walls:
1487
1594
  if _circle_wall_collision(car_center, self.collision_radius, wall):
@@ -8,11 +8,6 @@ from ..entities_constants import ZOMBIE_AGING_DURATION_FRAMES
8
8
  SURVIVOR_SPEED_PENALTY_PER_PASSENGER = 0.08
9
9
  SURVIVOR_OVERLOAD_DAMAGE_RATIO = 0.2
10
10
  SURVIVOR_MESSAGE_DURATION_MS = 2000
11
- SURVIVOR_CONVERSION_LINE_KEYS = [
12
- "stages.stage4.conversion_lines.line1",
13
- "stages.stage4.conversion_lines.line2",
14
- "stages.stage4.conversion_lines.line3",
15
- ]
16
11
 
17
12
  # --- Footprint settings (gameplay) ---
18
13
  FOOTPRINT_STEP_DISTANCE = 40
@@ -34,7 +29,6 @@ __all__ = [
34
29
  "SURVIVOR_SPEED_PENALTY_PER_PASSENGER",
35
30
  "SURVIVOR_OVERLOAD_DAMAGE_RATIO",
36
31
  "SURVIVOR_MESSAGE_DURATION_MS",
37
- "SURVIVOR_CONVERSION_LINE_KEYS",
38
32
  "FOOTPRINT_STEP_DISTANCE",
39
33
  "FOOTPRINT_MAX",
40
34
  "MAX_ZOMBIES",
@@ -74,6 +74,7 @@ def generate_level_from_blueprint(
74
74
  player_cells: list[pygame.Rect] = []
75
75
  car_cells: list[pygame.Rect] = []
76
76
  zombie_cells: list[pygame.Rect] = []
77
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] = {}
77
78
  palette = get_environment_palette(game_data.state.ambient_palette_key)
78
79
 
79
80
  def add_beam_to_groups(beam: SteelBeam) -> None:
@@ -139,6 +140,8 @@ def generate_level_from_blueprint(
139
140
  and not _has_wall(x - 1, y)
140
141
  and not _has_wall(x - 1, y + 1),
141
142
  )
143
+ if any(bevel_mask):
144
+ bevel_corners[(x, y)] = bevel_mask
142
145
  wall = Wall(
143
146
  cell_rect.x,
144
147
  cell_rect.y,
@@ -181,6 +184,8 @@ def generate_level_from_blueprint(
181
184
  game_data.layout.outside_rects = outside_rects
182
185
  game_data.layout.walkable_cells = walkable_cells
183
186
  game_data.layout.outer_wall_cells = outer_wall_cells
187
+ game_data.layout.wall_cells = wall_cells
188
+ game_data.layout.bevel_corners = bevel_corners
184
189
 
185
190
  return {
186
191
  "player_cells": player_cells,
@@ -5,7 +5,16 @@ from typing import Any, Sequence
5
5
 
6
6
  import pygame
7
7
 
8
- from ..entities import Car, Player, Survivor, Wall, WallIndex, Zombie, walls_for_radius
8
+ from ..entities import (
9
+ Car,
10
+ Player,
11
+ Survivor,
12
+ Wall,
13
+ WallIndex,
14
+ Zombie,
15
+ apply_tile_edge_nudge,
16
+ walls_for_radius,
17
+ )
9
18
  from ..entities_constants import (
10
19
  CAR_SPEED,
11
20
  PLAYER_SPEED,
@@ -20,7 +29,10 @@ from .utils import rect_visible_on_screen
20
29
 
21
30
 
22
31
  def process_player_input(
23
- keys: Sequence[bool], player: Player, car: Car | None
32
+ keys: Sequence[bool],
33
+ player: Player,
34
+ car: Car | None,
35
+ pad_input: tuple[float, float] = (0.0, 0.0),
24
36
  ) -> tuple[float, float, float, float]:
25
37
  """Process keyboard input and return movement deltas."""
26
38
  dx_input, dy_input = 0, 0
@@ -32,6 +44,8 @@ def process_player_input(
32
44
  dx_input -= 1
33
45
  if keys[pygame.K_d] or keys[pygame.K_RIGHT]:
34
46
  dx_input += 1
47
+ dx_input += pad_input[0]
48
+ dy_input += pad_input[1]
35
49
 
36
50
  player_dx, player_dy, car_dx, car_dy = 0, 0, 0, 0
37
51
 
@@ -75,6 +89,8 @@ def update_entities(
75
89
  camera = game_data.camera
76
90
  stage = game_data.stage
77
91
  active_car = car if car and car.alive() else None
92
+ wall_cells = game_data.layout.wall_cells
93
+ bevel_corners = game_data.layout.bevel_corners
78
94
 
79
95
  all_walls = list(wall_group) if wall_index is None else None
80
96
 
@@ -92,14 +108,36 @@ def update_entities(
92
108
 
93
109
  # Update player/car movement
94
110
  if player.in_car and active_car:
111
+ car_dx, car_dy = apply_tile_edge_nudge(
112
+ active_car.x,
113
+ active_car.y,
114
+ car_dx,
115
+ car_dy,
116
+ cell_size=game_data.cell_size,
117
+ wall_cells=wall_cells,
118
+ bevel_corners=bevel_corners,
119
+ grid_cols=stage.grid_cols,
120
+ grid_rows=stage.grid_rows,
121
+ )
95
122
  car_walls = _walls_near((active_car.x, active_car.y), 150.0)
96
- active_car.move(car_dx, car_dy, car_walls)
123
+ active_car.move(car_dx, car_dy, car_walls, walls_nearby=wall_index is not None)
97
124
  player.rect.center = active_car.rect.center
98
125
  player.x, player.y = active_car.x, active_car.y
99
126
  elif not player.in_car:
100
127
  # Ensure player is in all_sprites if not in car
101
128
  if player not in all_sprites:
102
129
  all_sprites.add(player, layer=2)
130
+ player_dx, player_dy = apply_tile_edge_nudge(
131
+ player.x,
132
+ player.y,
133
+ player_dx,
134
+ player_dy,
135
+ cell_size=game_data.cell_size,
136
+ wall_cells=wall_cells,
137
+ bevel_corners=bevel_corners,
138
+ grid_cols=stage.grid_cols,
139
+ grid_rows=stage.grid_rows,
140
+ )
103
141
  player.move(
104
142
  player_dx,
105
143
  player_dy,
@@ -217,4 +255,6 @@ def update_entities(
217
255
  level_width=game_data.level_width,
218
256
  level_height=game_data.level_height,
219
257
  outer_wall_cells=game_data.layout.outer_wall_cells,
258
+ wall_cells=game_data.layout.wall_cells,
259
+ bevel_corners=game_data.layout.bevel_corners,
220
260
  )
@@ -58,6 +58,35 @@ def _car_appearance_for_stage(stage: Stage | None) -> str:
58
58
  return "disabled" if stage and stage.survival_stage else "default"
59
59
 
60
60
 
61
+ def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
62
+ normal_ratio = 1.0
63
+ tracker_ratio = 0.0
64
+ wall_follower_ratio = 0.0
65
+ if stage is not None:
66
+ normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
67
+ tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
68
+ wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
69
+ if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
70
+ normal_ratio = 1.0
71
+ tracker_ratio = 0.0
72
+ wall_follower_ratio = 0.0
73
+ if (
74
+ normal_ratio == 1.0
75
+ and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
76
+ and tracker_ratio + wall_follower_ratio <= 1.0
77
+ ):
78
+ normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
79
+ total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
80
+ if total_ratio <= 0:
81
+ return False, False
82
+ pick = RNG.random() * total_ratio
83
+ if pick < normal_ratio:
84
+ return False, False
85
+ if pick < normal_ratio + tracker_ratio:
86
+ return True, False
87
+ return False, True
88
+
89
+
61
90
  def _create_zombie(
62
91
  config: dict[str, Any],
63
92
  *,
@@ -75,44 +104,19 @@ def _create_zombie(
75
104
  else:
76
105
  base_speed = ZOMBIE_SPEED
77
106
  base_speed = min(base_speed, PLAYER_SPEED - 0.05)
78
- normal_ratio = 1.0
79
- tracker_ratio = 0.0
80
- wall_follower_ratio = 0.0
81
107
  if stage is not None:
82
- normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
83
- tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
84
- wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
85
- if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
86
- normal_ratio = 1.0
87
- tracker_ratio = 0.0
88
- wall_follower_ratio = 0.0
89
- if (
90
- normal_ratio == 1.0
91
- and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
92
- and tracker_ratio + wall_follower_ratio <= 1.0
93
- ):
94
- normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
95
108
  aging_duration_frames = max(
96
109
  1.0,
97
110
  float(stage.zombie_aging_duration_frames),
98
111
  )
99
112
  else:
100
113
  aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
101
- picked_tracker = False
102
- picked_wall_follower = False
103
- total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
104
- if total_ratio > 0:
105
- pick = RNG.random() * total_ratio
106
- if pick < normal_ratio:
107
- pass
108
- elif pick < normal_ratio + tracker_ratio:
109
- picked_tracker = True
110
- else:
111
- picked_wall_follower = True
112
- if tracker is None:
113
- tracker = picked_tracker
114
- if wall_follower is None:
115
- wall_follower = picked_wall_follower
114
+ if tracker is None or wall_follower is None:
115
+ picked_tracker, picked_wall_follower = _pick_zombie_variant(stage)
116
+ if tracker is None:
117
+ tracker = picked_tracker
118
+ if wall_follower is None:
119
+ wall_follower = picked_wall_follower
116
120
  if tracker:
117
121
  wall_follower = False
118
122
  if tracker:
@@ -440,10 +444,13 @@ def spawn_initial_zombies(
440
444
  )
441
445
 
442
446
  for pos in positions:
447
+ tracker, wall_follower = _pick_zombie_variant(game_data.stage)
443
448
  tentative = _create_zombie(
444
449
  config,
445
450
  start_pos=pos,
446
451
  stage=game_data.stage,
452
+ tracker=tracker,
453
+ wall_follower=wall_follower,
447
454
  )
448
455
  if spritecollideany_walls(tentative, wall_group):
449
456
  continue
@@ -83,6 +83,8 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
83
83
  outside_rects=[],
84
84
  walkable_cells=[],
85
85
  outer_wall_cells=set(),
86
+ wall_cells=set(),
87
+ bevel_corners={},
86
88
  ),
87
89
  fog={
88
90
  "hatch_patterns": {},