zombie-escape 1.13.1__py3-none-any.whl → 1.14.4__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 (41) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/colors.py +7 -21
  3. zombie_escape/entities.py +100 -191
  4. zombie_escape/export_images.py +39 -33
  5. zombie_escape/gameplay/ambient.py +2 -6
  6. zombie_escape/gameplay/footprints.py +8 -11
  7. zombie_escape/gameplay/interactions.py +17 -58
  8. zombie_escape/gameplay/layout.py +20 -46
  9. zombie_escape/gameplay/movement.py +7 -21
  10. zombie_escape/gameplay/spawn.py +12 -40
  11. zombie_escape/gameplay/state.py +1 -0
  12. zombie_escape/gameplay/survivors.py +5 -16
  13. zombie_escape/gameplay/utils.py +4 -13
  14. zombie_escape/input_utils.py +8 -31
  15. zombie_escape/level_blueprints.py +112 -69
  16. zombie_escape/level_constants.py +8 -0
  17. zombie_escape/locales/ui.en.json +12 -0
  18. zombie_escape/locales/ui.ja.json +12 -0
  19. zombie_escape/localization.py +3 -11
  20. zombie_escape/models.py +26 -9
  21. zombie_escape/render/__init__.py +30 -0
  22. zombie_escape/render/core.py +992 -0
  23. zombie_escape/render/hud.py +444 -0
  24. zombie_escape/render/overview.py +218 -0
  25. zombie_escape/render/shadows.py +343 -0
  26. zombie_escape/render_assets.py +11 -33
  27. zombie_escape/rng.py +4 -8
  28. zombie_escape/screens/__init__.py +14 -30
  29. zombie_escape/screens/game_over.py +43 -15
  30. zombie_escape/screens/gameplay.py +41 -104
  31. zombie_escape/screens/settings.py +19 -104
  32. zombie_escape/screens/title.py +36 -176
  33. zombie_escape/stage_constants.py +192 -67
  34. zombie_escape/zombie_escape.py +1 -1
  35. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/METADATA +100 -39
  36. zombie_escape-1.14.4.dist-info/RECORD +53 -0
  37. zombie_escape/render.py +0 -1746
  38. zombie_escape-1.13.1.dist-info/RECORD +0 -49
  39. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/WHEEL +0 -0
  40. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/entry_points.txt +0 -0
  41. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/entities.py CHANGED
@@ -159,9 +159,7 @@ class Wall(pygame.sprite.Sprite):
159
159
  self.draw_bottom_side = draw_bottom_side
160
160
  self.bottom_side_ratio = max(0.0, bottom_side_ratio)
161
161
  self.side_shade_ratio = max(0.0, min(1.0, side_shade_ratio))
162
- self._local_polygon = _build_beveled_polygon(
163
- safe_width, safe_height, self.bevel_depth, self.bevel_mask
164
- )
162
+ self._local_polygon = _build_beveled_polygon(safe_width, safe_height, self.bevel_depth, self.bevel_mask)
165
163
  self._update_color()
166
164
  self.rect = self.image.get_rect(topleft=(x, y))
167
165
  # Keep collision rectangular even when beveled visually.
@@ -205,18 +203,14 @@ class Wall(pygame.sprite.Sprite):
205
203
  return self.rect.colliderect(rect_obj)
206
204
  return _rect_polygon_collision(rect_obj, self._collision_polygon)
207
205
 
208
- def _collides_circle(
209
- self: Self, center: tuple[float, float], radius: float
210
- ) -> bool:
206
+ def _collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
211
207
  if not _circle_rect_collision(center, radius, self.rect):
212
208
  return False
213
209
  if self._collision_polygon is None:
214
210
  return True
215
211
  return _circle_polygon_collision(center, radius, self._collision_polygon)
216
212
 
217
- def set_palette(
218
- self: Self, palette: EnvironmentPalette | None, *, force: bool = False
219
- ) -> None:
213
+ def set_palette(self: Self, palette: EnvironmentPalette | None, *, force: bool = False) -> None:
220
214
  """Update the wall's palette to match the current ambient palette."""
221
215
 
222
216
  if not force and self.palette is palette:
@@ -241,15 +235,9 @@ class RubbleWall(Wall):
241
235
  rubble_offset_px: int | None = None,
242
236
  on_destroy: Callable[[Self], None] | None = None,
243
237
  ) -> None:
244
- self._rubble_rotation_deg = (
245
- RUBBLE_ROTATION_DEG if rubble_rotation_deg is None else rubble_rotation_deg
246
- )
238
+ self._rubble_rotation_deg = RUBBLE_ROTATION_DEG if rubble_rotation_deg is None else rubble_rotation_deg
247
239
  base_size = max(1, min(width, height))
248
- self._rubble_offset_px = (
249
- rubble_offset_for_size(base_size)
250
- if rubble_offset_px is None
251
- else rubble_offset_px
252
- )
240
+ self._rubble_offset_px = rubble_offset_for_size(base_size) if rubble_offset_px is None else rubble_offset_px
253
241
  super().__init__(
254
242
  x,
255
243
  y,
@@ -322,9 +310,7 @@ class SteelBeam(pygame.sprite.Sprite):
322
310
  if self.health <= 0:
323
311
  return
324
312
  health_ratio = max(0, self.health / self.max_health)
325
- base_color, line_color = resolve_steel_beam_colors(
326
- health_ratio=health_ratio, palette=self.palette
327
- )
313
+ base_color, line_color = resolve_steel_beam_colors(health_ratio=health_ratio, palette=self.palette)
328
314
  paint_steel_beam_surface(
329
315
  self.image,
330
316
  base_color=base_color,
@@ -387,9 +373,7 @@ def _walls_for_sprite(
387
373
  )
388
374
 
389
375
 
390
- def _circle_rect_collision(
391
- center: tuple[float, float], radius: float, rect_obj: rect.Rect
392
- ) -> bool:
376
+ def _circle_rect_collision(center: tuple[float, float], radius: float, rect_obj: rect.Rect) -> bool:
393
377
  """Return True if a circle overlaps the provided rectangle."""
394
378
  cx, cy = center
395
379
  closest_x = max(rect_obj.left, min(cx, rect_obj.right))
@@ -408,9 +392,7 @@ def _build_beveled_polygon(
408
392
  return build_beveled_polygon(width, height, depth, bevels)
409
393
 
410
394
 
411
- def _point_in_polygon(
412
- point: tuple[float, float], polygon: Sequence[tuple[float, float]]
413
- ) -> bool:
395
+ def _point_in_polygon(point: tuple[float, float], polygon: Sequence[tuple[float, float]]) -> bool:
414
396
  x, y = point
415
397
  inside = False
416
398
  count = len(polygon)
@@ -418,9 +400,7 @@ def _point_in_polygon(
418
400
  for i in range(count):
419
401
  xi, yi = polygon[i]
420
402
  xj, yj = polygon[j]
421
- intersects = (yi > y) != (yj > y) and (
422
- x < (xj - xi) * (y - yi) / (yj - yi + 0.000001) + xi
423
- )
403
+ intersects = (yi > y) != (yj > y) and (x < (xj - xi) * (y - yi) / (yj - yi + 0.000001) + xi)
424
404
  if intersects:
425
405
  inside = not inside
426
406
  j = i
@@ -433,17 +413,11 @@ def _segments_intersect(
433
413
  b1: tuple[float, float],
434
414
  b2: tuple[float, float],
435
415
  ) -> bool:
436
- def orient(
437
- p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]
438
- ) -> float:
416
+ def orient(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float:
439
417
  return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
440
418
 
441
- def on_segment(
442
- p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]
443
- ) -> bool:
444
- return min(p[0], r[0]) <= q[0] <= max(p[0], r[0]) and min(p[1], r[1]) <= q[
445
- 1
446
- ] <= max(p[1], r[1])
419
+ def on_segment(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> bool:
420
+ return min(p[0], r[0]) <= q[0] <= max(p[0], r[0]) and min(p[1], r[1]) <= q[1] <= max(p[1], r[1])
447
421
 
448
422
  o1 = orient(a1, a2, b1)
449
423
  o2 = orient(a1, a2, b2)
@@ -507,16 +481,12 @@ def _line_of_sight_clear(
507
481
  return True
508
482
 
509
483
 
510
- def _rect_polygon_collision(
511
- rect_obj: rect.Rect, polygon: Sequence[tuple[float, float]]
512
- ) -> bool:
484
+ def _rect_polygon_collision(rect_obj: rect.Rect, polygon: Sequence[tuple[float, float]]) -> bool:
513
485
  min_x = min(p[0] for p in polygon)
514
486
  max_x = max(p[0] for p in polygon)
515
487
  min_y = min(p[1] for p in polygon)
516
488
  max_y = max(p[1] for p in polygon)
517
- if not rect_obj.colliderect(
518
- pygame.Rect(min_x, min_y, max_x - min_x, max_y - min_y)
519
- ):
489
+ if not rect_obj.colliderect(pygame.Rect(min_x, min_y, max_x - min_x, max_y - min_y)):
520
490
  return False
521
491
 
522
492
  rect_points = [
@@ -536,9 +506,7 @@ def _rect_polygon_collision(
536
506
  (rect_points[2], rect_points[3]),
537
507
  (rect_points[3], rect_points[0]),
538
508
  ]
539
- poly_edges = [
540
- (polygon[i], polygon[(i + 1) % len(polygon)]) for i in range(len(polygon))
541
- ]
509
+ poly_edges = [(polygon[i], polygon[(i + 1) % len(polygon)]) for i in range(len(polygon))]
542
510
  for edge_a in rect_edges:
543
511
  for edge_b in poly_edges:
544
512
  if _segments_intersect(edge_a[0], edge_a[1], edge_b[0], edge_b[1]):
@@ -562,9 +530,7 @@ def _circle_polygon_collision(
562
530
  return False
563
531
 
564
532
 
565
- def _collide_sprite_wall(
566
- sprite: pygame.sprite.Sprite, wall: pygame.sprite.Sprite
567
- ) -> bool:
533
+ def _collide_sprite_wall(sprite: pygame.sprite.Sprite, wall: pygame.sprite.Sprite) -> bool:
568
534
  if hasattr(sprite, "radius"):
569
535
  center = sprite.rect.center
570
536
  radius = float(sprite.radius)
@@ -591,9 +557,7 @@ def _spritecollide_walls(
591
557
  if wall_index is None:
592
558
  return cast(
593
559
  list[Wall],
594
- pygame.sprite.spritecollide(
595
- sprite, walls, dokill, collided=_collide_sprite_wall
596
- ),
560
+ pygame.sprite.spritecollide(sprite, walls, dokill, collided=_collide_sprite_wall),
597
561
  )
598
562
  if cell_size is None:
599
563
  raise ValueError("cell_size is required when using wall_index")
@@ -625,9 +589,7 @@ def spritecollideany_walls(
625
589
  if wall_index is None:
626
590
  return cast(
627
591
  Wall | None,
628
- pygame.sprite.spritecollideany(
629
- sprite, walls, collided=_collide_sprite_wall
630
- ),
592
+ pygame.sprite.spritecollideany(sprite, walls, collided=_collide_sprite_wall),
631
593
  )
632
594
  if cell_size is None:
633
595
  raise ValueError("cell_size is required when using wall_index")
@@ -725,9 +687,7 @@ class Player(pygame.sprite.Sprite):
725
687
  self.is_jumping = False
726
688
  self._update_image_scale(1.0)
727
689
  else:
728
- self._update_image_scale(
729
- _get_jump_scale(elapsed, self.jump_duration, JUMP_SCALE_MAX)
730
- )
690
+ self._update_image_scale(_get_jump_scale(elapsed, self.jump_duration, JUMP_SCALE_MAX))
731
691
 
732
692
  # Pre-calculate jump possibility based on actual movement vector
733
693
  can_jump_now = (
@@ -735,9 +695,7 @@ class Player(pygame.sprite.Sprite):
735
695
  and pitfall_cells
736
696
  and cell_size
737
697
  and walkable_cells
738
- and _can_humanoid_jump(
739
- self.x, self.y, dx, dy, PLAYER_JUMP_RANGE, cell_size, walkable_cells
740
- )
698
+ and _can_humanoid_jump(self.x, self.y, dx, dy, PLAYER_JUMP_RANGE, cell_size, walkable_cells)
741
699
  )
742
700
 
743
701
  inner_wall_hit = False
@@ -833,9 +791,7 @@ class Player(pygame.sprite.Sprite):
833
791
  self.image = base_img
834
792
  else:
835
793
  w, h = base_img.get_size()
836
- self.image = pygame.transform.scale(
837
- base_img, (int(w * scale), int(h * scale))
838
- )
794
+ self.image = pygame.transform.scale(base_img, (int(w * scale), int(h * scale)))
839
795
  old_center = self.rect.center
840
796
  self.rect = self.image.get_rect(center=old_center)
841
797
 
@@ -929,8 +885,7 @@ class Survivor(pygame.sprite.Sprite):
929
885
  wall_cells: set[tuple[int, int]] | None = None,
930
886
  pitfall_cells: set[tuple[int, int]] | None = None,
931
887
  walkable_cells: list[tuple[int, int]] | None = None,
932
- bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
933
- | None = None,
888
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] | None = None,
934
889
  grid_cols: int | None = None,
935
890
  grid_rows: int | None = None,
936
891
  level_width: int | None = None,
@@ -947,9 +902,7 @@ class Survivor(pygame.sprite.Sprite):
947
902
  self.is_jumping = False
948
903
  self._update_image_scale(1.0)
949
904
  else:
950
- self._update_image_scale(
951
- _get_jump_scale(elapsed, self.jump_duration, JUMP_SCALE_MAX)
952
- )
905
+ self._update_image_scale(_get_jump_scale(elapsed, self.jump_duration, JUMP_SCALE_MAX))
953
906
 
954
907
  if self.is_buddy:
955
908
  if self.rescued or not self.following:
@@ -975,12 +928,7 @@ class Survivor(pygame.sprite.Sprite):
975
928
  move_x = (dx / dist) * BUDDY_FOLLOW_SPEED
976
929
  move_y = (dy / dist) * BUDDY_FOLLOW_SPEED
977
930
 
978
- if (
979
- cell_size is not None
980
- and wall_cells is not None
981
- and grid_cols is not None
982
- and grid_rows is not None
983
- ):
931
+ if cell_size is not None and wall_cells is not None and grid_cols is not None and grid_rows is not None:
984
932
  move_x, move_y = apply_tile_edge_nudge(
985
933
  self.x,
986
934
  self.y,
@@ -1095,10 +1043,7 @@ class Survivor(pygame.sprite.Sprite):
1095
1043
  dx = player_pos[0] - self.x
1096
1044
  dy = player_pos[1] - self.y
1097
1045
  dist_sq = dx * dx + dy * dy
1098
- if (
1099
- dist_sq <= 0
1100
- or dist_sq > SURVIVOR_APPROACH_RADIUS * SURVIVOR_APPROACH_RADIUS
1101
- ):
1046
+ if dist_sq <= 0 or dist_sq > SURVIVOR_APPROACH_RADIUS * SURVIVOR_APPROACH_RADIUS:
1102
1047
  return
1103
1048
 
1104
1049
  dist = math.sqrt(dist_sq)
@@ -1124,12 +1069,7 @@ class Survivor(pygame.sprite.Sprite):
1124
1069
  )
1125
1070
  )
1126
1071
 
1127
- if (
1128
- cell_size is not None
1129
- and wall_cells is not None
1130
- and grid_cols is not None
1131
- and grid_rows is not None
1132
- ):
1072
+ if cell_size is not None and wall_cells is not None and grid_cols is not None and grid_rows is not None:
1133
1073
  move_x, move_y = apply_tile_edge_nudge(
1134
1074
  self.x,
1135
1075
  self.y,
@@ -1203,9 +1143,7 @@ class Survivor(pygame.sprite.Sprite):
1203
1143
  self.image = base_img
1204
1144
  else:
1205
1145
  w, h = base_img.get_size()
1206
- self.image = pygame.transform.scale(
1207
- base_img, (int(w * scale), int(h * scale))
1208
- )
1146
+ self.image = pygame.transform.scale(base_img, (int(w * scale), int(h * scale)))
1209
1147
  old_center = self.rect.center
1210
1148
  self.rect = self.image.get_rect(center=old_center)
1211
1149
 
@@ -1240,9 +1178,7 @@ class Survivor(pygame.sprite.Sprite):
1240
1178
  self.rect = self.image.get_rect(center=center)
1241
1179
 
1242
1180
 
1243
- def random_position_outside_building(
1244
- level_width: int, level_height: int
1245
- ) -> tuple[int, int]:
1181
+ def random_position_outside_building(level_width: int, level_height: int) -> tuple[int, int]:
1246
1182
  side = RNG.choice(["top", "bottom", "left", "right"])
1247
1183
  margin = 0
1248
1184
  if side == "top":
@@ -1266,6 +1202,7 @@ def _zombie_tracker_movement(
1266
1202
  grid_cols: int,
1267
1203
  grid_rows: int,
1268
1204
  outer_wall_cells: set[tuple[int, int]] | None,
1205
+ pitfall_cells: set[tuple[int, int]] | None,
1269
1206
  ) -> tuple[float, float]:
1270
1207
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
1271
1208
  if not is_in_sight:
@@ -1273,9 +1210,7 @@ def _zombie_tracker_movement(
1273
1210
  last_target_time = zombie.tracker_target_time
1274
1211
  if last_target_time is None:
1275
1212
  last_target_time = pygame.time.get_ticks()
1276
- zombie.tracker_relock_after_time = (
1277
- last_target_time + ZOMBIE_TRACKER_RELOCK_DELAY_MS
1278
- )
1213
+ zombie.tracker_relock_after_time = last_target_time + ZOMBIE_TRACKER_RELOCK_DELAY_MS
1279
1214
  zombie.tracker_target_pos = None
1280
1215
  return _zombie_wander_move(
1281
1216
  zombie,
@@ -1284,6 +1219,7 @@ def _zombie_tracker_movement(
1284
1219
  grid_cols=grid_cols,
1285
1220
  grid_rows=grid_rows,
1286
1221
  outer_wall_cells=outer_wall_cells,
1222
+ pitfall_cells=pitfall_cells,
1287
1223
  )
1288
1224
  _zombie_update_tracker_target(zombie, footprints, walls)
1289
1225
  if zombie.tracker_target_pos is not None:
@@ -1295,6 +1231,7 @@ def _zombie_tracker_movement(
1295
1231
  grid_cols=grid_cols,
1296
1232
  grid_rows=grid_rows,
1297
1233
  outer_wall_cells=outer_wall_cells,
1234
+ pitfall_cells=pitfall_cells,
1298
1235
  )
1299
1236
  return _zombie_move_toward(zombie, player_center)
1300
1237
 
@@ -1309,6 +1246,7 @@ def _zombie_wander_movement(
1309
1246
  grid_cols: int,
1310
1247
  grid_rows: int,
1311
1248
  outer_wall_cells: set[tuple[int, int]] | None,
1249
+ pitfall_cells: set[tuple[int, int]] | None,
1312
1250
  ) -> tuple[float, float]:
1313
1251
  return _zombie_wander_move(
1314
1252
  zombie,
@@ -1317,6 +1255,7 @@ def _zombie_wander_movement(
1317
1255
  grid_cols=grid_cols,
1318
1256
  grid_rows=grid_rows,
1319
1257
  outer_wall_cells=outer_wall_cells,
1258
+ pitfall_cells=pitfall_cells,
1320
1259
  )
1321
1260
 
1322
1261
 
@@ -1329,15 +1268,9 @@ def _zombie_wall_hug_has_wall(
1329
1268
  check_x = zombie.x + math.cos(angle) * distance
1330
1269
  check_y = zombie.y + math.sin(angle) * distance
1331
1270
  candidates = [
1332
- wall
1333
- for wall in walls
1334
- if abs(wall.rect.centerx - check_x) < 120
1335
- and abs(wall.rect.centery - check_y) < 120
1271
+ wall for wall in walls if abs(wall.rect.centerx - check_x) < 120 and abs(wall.rect.centery - check_y) < 120
1336
1272
  ]
1337
- return any(
1338
- _circle_wall_collision((check_x, check_y), zombie.radius, wall)
1339
- for wall in candidates
1340
- )
1273
+ return any(_circle_wall_collision((check_x, check_y), zombie.radius, wall) for wall in candidates)
1341
1274
 
1342
1275
 
1343
1276
  def _zombie_wall_hug_wall_distance(
@@ -1354,8 +1287,7 @@ def _zombie_wall_hug_wall_distance(
1354
1287
  candidates = [
1355
1288
  wall
1356
1289
  for wall in walls
1357
- if abs(wall.rect.centerx - zombie.x) < max_search
1358
- and abs(wall.rect.centery - zombie.y) < max_search
1290
+ if abs(wall.rect.centerx - zombie.x) < max_search and abs(wall.rect.centery - zombie.y) < max_search
1359
1291
  ]
1360
1292
  if not candidates:
1361
1293
  return max_distance
@@ -1363,10 +1295,7 @@ def _zombie_wall_hug_wall_distance(
1363
1295
  while distance <= max_distance:
1364
1296
  check_x = zombie.x + direction_x * distance
1365
1297
  check_y = zombie.y + direction_y * distance
1366
- if any(
1367
- _circle_wall_collision((check_x, check_y), zombie.radius, wall)
1368
- for wall in candidates
1369
- ):
1298
+ if any(_circle_wall_collision((check_x, check_y), zombie.radius, wall) for wall in candidates):
1370
1299
  return distance
1371
1300
  distance += step
1372
1301
  return max_distance
@@ -1382,6 +1311,7 @@ def _zombie_wall_hug_movement(
1382
1311
  grid_cols: int,
1383
1312
  grid_rows: int,
1384
1313
  outer_wall_cells: set[tuple[int, int]] | None,
1314
+ pitfall_cells: set[tuple[int, int]] | None,
1385
1315
  ) -> tuple[float, float]:
1386
1316
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
1387
1317
  if zombie.wall_hug_angle is None:
@@ -1392,15 +1322,9 @@ def _zombie_wall_hug_movement(
1392
1322
  probe_offset = math.radians(ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG)
1393
1323
  left_angle = forward_angle + probe_offset
1394
1324
  right_angle = forward_angle - probe_offset
1395
- left_dist = _zombie_wall_hug_wall_distance(
1396
- zombie, walls, left_angle, sensor_distance
1397
- )
1398
- right_dist = _zombie_wall_hug_wall_distance(
1399
- zombie, walls, right_angle, sensor_distance
1400
- )
1401
- forward_dist = _zombie_wall_hug_wall_distance(
1402
- zombie, walls, forward_angle, sensor_distance
1403
- )
1325
+ left_dist = _zombie_wall_hug_wall_distance(zombie, walls, left_angle, sensor_distance)
1326
+ right_dist = _zombie_wall_hug_wall_distance(zombie, walls, right_angle, sensor_distance)
1327
+ forward_dist = _zombie_wall_hug_wall_distance(zombie, walls, forward_angle, sensor_distance)
1404
1328
  left_wall = left_dist < sensor_distance
1405
1329
  right_wall = right_dist < sensor_distance
1406
1330
  forward_wall = forward_dist < sensor_distance
@@ -1425,17 +1349,14 @@ def _zombie_wall_hug_movement(
1425
1349
  grid_cols=grid_cols,
1426
1350
  grid_rows=grid_rows,
1427
1351
  outer_wall_cells=outer_wall_cells,
1352
+ pitfall_cells=pitfall_cells,
1428
1353
  )
1429
1354
 
1430
1355
  sensor_distance = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius
1431
1356
  probe_offset = math.radians(ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG)
1432
1357
  side_angle = zombie.wall_hug_angle + zombie.wall_hug_side * probe_offset
1433
- side_dist = _zombie_wall_hug_wall_distance(
1434
- zombie, walls, side_angle, sensor_distance
1435
- )
1436
- forward_dist = _zombie_wall_hug_wall_distance(
1437
- zombie, walls, zombie.wall_hug_angle, sensor_distance
1438
- )
1358
+ side_dist = _zombie_wall_hug_wall_distance(zombie, walls, side_angle, sensor_distance)
1359
+ forward_dist = _zombie_wall_hug_wall_distance(zombie, walls, zombie.wall_hug_angle, sensor_distance)
1439
1360
  side_has_wall = side_dist < sensor_distance
1440
1361
  forward_has_wall = forward_dist < sensor_distance
1441
1362
  now = pygame.time.get_ticks()
@@ -1487,6 +1408,7 @@ def _zombie_normal_movement(
1487
1408
  grid_cols: int,
1488
1409
  grid_rows: int,
1489
1410
  outer_wall_cells: set[tuple[int, int]] | None,
1411
+ pitfall_cells: set[tuple[int, int]] | None,
1490
1412
  ) -> tuple[float, float]:
1491
1413
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_SIGHT_RANGE)
1492
1414
  if not is_in_sight:
@@ -1497,6 +1419,7 @@ def _zombie_normal_movement(
1497
1419
  grid_cols=grid_cols,
1498
1420
  grid_rows=grid_rows,
1499
1421
  outer_wall_cells=outer_wall_cells,
1422
+ pitfall_cells=pitfall_cells,
1500
1423
  )
1501
1424
  return _zombie_move_toward(zombie, player_center)
1502
1425
 
@@ -1525,12 +1448,9 @@ def _zombie_update_tracker_target(
1525
1448
  if latest_fp_time is None or fp.time > latest_fp_time:
1526
1449
  latest_fp_time = fp.time
1527
1450
  use_far_scan = last_target_time is None or (
1528
- latest_fp_time is not None
1529
- and latest_fp_time - last_target_time >= ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS
1530
- )
1531
- scan_radius = (
1532
- ZOMBIE_TRACKER_FAR_SCENT_RADIUS if use_far_scan else ZOMBIE_TRACKER_SCENT_RADIUS
1451
+ latest_fp_time is not None and latest_fp_time - last_target_time >= ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS
1533
1452
  )
1453
+ scan_radius = ZOMBIE_TRACKER_FAR_SCENT_RADIUS if use_far_scan else ZOMBIE_TRACKER_SCENT_RADIUS
1534
1454
  scent_radius_sq = scan_radius * scan_radius
1535
1455
  min_target_dist_sq = (FOOTPRINT_STEP_DISTANCE * 0.5) ** 2
1536
1456
  for fp in footprints:
@@ -1548,13 +1468,23 @@ def _zombie_update_tracker_target(
1548
1468
  if not nearby:
1549
1469
  return
1550
1470
 
1551
- nearby.sort(key=lambda fp: fp.time, reverse=True)
1471
+ nearby.sort(key=lambda fp: fp.time)
1552
1472
  if last_target_time is not None:
1553
1473
  newer = [fp for fp in nearby if fp.time > last_target_time]
1554
1474
  else:
1555
1475
  newer = nearby
1556
1476
 
1557
- for fp in newer[:ZOMBIE_TRACKER_SCENT_TOP_K]:
1477
+ if use_far_scan or last_target_time is None:
1478
+ candidates = list(reversed(newer))[:ZOMBIE_TRACKER_SCENT_TOP_K]
1479
+ else:
1480
+ newer_threshold = last_target_time + ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS
1481
+ very_new = [fp for fp in newer if fp.time >= newer_threshold]
1482
+ if very_new:
1483
+ candidates = list(reversed(very_new))[:ZOMBIE_TRACKER_SCENT_TOP_K]
1484
+ else:
1485
+ candidates = newer[:ZOMBIE_TRACKER_SCENT_TOP_K]
1486
+
1487
+ for fp in candidates:
1558
1488
  pos = fp.pos
1559
1489
  fp_time = fp.time
1560
1490
  if _line_of_sight_clear((zombie.x, zombie.y), pos, walls):
@@ -1566,8 +1496,7 @@ def _zombie_update_tracker_target(
1566
1496
 
1567
1497
  if (
1568
1498
  zombie.tracker_target_pos is not None
1569
- and (zombie.x - zombie.tracker_target_pos[0]) ** 2
1570
- + (zombie.y - zombie.tracker_target_pos[1]) ** 2
1499
+ and (zombie.x - zombie.tracker_target_pos[0]) ** 2 + (zombie.y - zombie.tracker_target_pos[1]) ** 2
1571
1500
  > min_target_dist_sq
1572
1501
  ):
1573
1502
  return
@@ -1629,6 +1558,7 @@ def _zombie_wander_move(
1629
1558
  grid_cols: int,
1630
1559
  grid_rows: int,
1631
1560
  outer_wall_cells: set[tuple[int, int]] | None,
1561
+ pitfall_cells: set[tuple[int, int]] | None,
1632
1562
  ) -> tuple[float, float]:
1633
1563
  now = pygame.time.get_ticks()
1634
1564
  changed_angle = False
@@ -1676,13 +1606,9 @@ def _zombie_wander_move(
1676
1606
  nearby_walls = [
1677
1607
  wall
1678
1608
  for wall in walls
1679
- if abs(wall.rect.centerx - next_x) < 120
1680
- and abs(wall.rect.centery - next_y) < 120
1609
+ if abs(wall.rect.centerx - next_x) < 120 and abs(wall.rect.centery - next_y) < 120
1681
1610
  ]
1682
- return not any(
1683
- _circle_wall_collision((next_x, next_y), zombie.radius, wall)
1684
- for wall in nearby_walls
1685
- )
1611
+ return not any(_circle_wall_collision((next_x, next_y), zombie.radius, wall) for wall in nearby_walls)
1686
1612
 
1687
1613
  if at_x_edge:
1688
1614
  inward_dx = zombie.speed if cell_x == 0 else -zombie.speed
@@ -1695,12 +1621,30 @@ def _zombie_wander_move(
1695
1621
 
1696
1622
  move_x = math.cos(zombie.wander_angle) * zombie.speed
1697
1623
  move_y = math.sin(zombie.wander_angle) * zombie.speed
1624
+ if pitfall_cells is not None:
1625
+ avoid_x, avoid_y = zombie._avoid_pitfalls(pitfall_cells, cell_size)
1626
+ move_x += avoid_x
1627
+ move_y += avoid_y
1628
+ if cell_size > 0:
1629
+ next_x = zombie.x + move_x
1630
+ next_y = zombie.y + move_y
1631
+ next_cell = (int(next_x // cell_size), int(next_y // cell_size))
1632
+ if next_cell in pitfall_cells:
1633
+ zombie.wander_angle = (zombie.wander_angle + math.pi) % math.tau
1634
+ move_x = math.cos(zombie.wander_angle) * zombie.speed
1635
+ move_y = math.sin(zombie.wander_angle) * zombie.speed
1636
+ avoid_x, avoid_y = zombie._avoid_pitfalls(pitfall_cells, cell_size)
1637
+ move_x += avoid_x
1638
+ move_y += avoid_y
1639
+ next_x = zombie.x + move_x
1640
+ next_y = zombie.y + move_y
1641
+ next_cell = (int(next_x // cell_size), int(next_y // cell_size))
1642
+ if next_cell in pitfall_cells:
1643
+ return 0.0, 0.0
1698
1644
  return move_x, move_y
1699
1645
 
1700
1646
 
1701
- def _zombie_move_toward(
1702
- zombie: Zombie, target: tuple[float, float]
1703
- ) -> tuple[float, float]:
1647
+ def _zombie_move_toward(zombie: Zombie, target: tuple[float, float]) -> tuple[float, float]:
1704
1648
  dx = target[0] - zombie.x
1705
1649
  dy = target[1] - zombie.y
1706
1650
  dist = math.hypot(dx, dy)
@@ -1763,19 +1707,13 @@ class Zombie(pygame.sprite.Sprite):
1763
1707
  self.wall_hug_stuck_flag = False
1764
1708
  self.pos_history: list[tuple[float, float]] = []
1765
1709
  self.wander_angle = RNG.uniform(0, math.tau)
1766
- self.wander_interval_ms = (
1767
- ZOMBIE_TRACKER_WANDER_INTERVAL_MS if tracker else ZOMBIE_WANDER_INTERVAL_MS
1768
- )
1710
+ self.wander_interval_ms = ZOMBIE_TRACKER_WANDER_INTERVAL_MS if tracker else ZOMBIE_WANDER_INTERVAL_MS
1769
1711
  self.last_wander_change_time = pygame.time.get_ticks()
1770
- self.wander_change_interval = max(
1771
- 0, self.wander_interval_ms + RNG.randint(-500, 500)
1772
- )
1712
+ self.wander_change_interval = max(0, self.wander_interval_ms + RNG.randint(-500, 500))
1773
1713
  self.last_move_dx = 0.0
1774
1714
  self.last_move_dy = 0.0
1775
1715
 
1776
- def _update_mode(
1777
- self: Self, player_center: tuple[int, int], sight_range: float
1778
- ) -> bool:
1716
+ def _update_mode(self: Self, player_center: tuple[int, int], sight_range: float) -> bool:
1779
1717
  dx_target = player_center[0] - self.x
1780
1718
  dy_target = player_center[1] - self.y
1781
1719
  dist_to_player_sq = dx_target * dx_target + dy_target * dy_target
@@ -1783,16 +1721,10 @@ class Zombie(pygame.sprite.Sprite):
1783
1721
  self.was_in_sight = is_in_sight
1784
1722
  return is_in_sight
1785
1723
 
1786
- def _handle_wall_collision(
1787
- self: Self, next_x: float, next_y: float, walls: list[Wall]
1788
- ) -> tuple[float, float]:
1724
+ def _handle_wall_collision(self: Self, next_x: float, next_y: float, walls: list[Wall]) -> tuple[float, float]:
1789
1725
  final_x, final_y = next_x, next_y
1790
1726
 
1791
- possible_walls = [
1792
- w
1793
- for w in walls
1794
- if abs(w.rect.centerx - self.x) < 100 and abs(w.rect.centery - self.y) < 100
1795
- ]
1727
+ possible_walls = [w for w in walls if abs(w.rect.centerx - self.x) < 100 and abs(w.rect.centery - self.y) < 100]
1796
1728
 
1797
1729
  for wall in possible_walls:
1798
1730
  collides = _circle_wall_collision((next_x, self.y), self.radius, wall)
@@ -1831,10 +1763,7 @@ class Zombie(pygame.sprite.Sprite):
1831
1763
  continue
1832
1764
  dx = other.x - next_x
1833
1765
  dy = other.y - next_y
1834
- if (
1835
- abs(dx) > ZOMBIE_SEPARATION_DISTANCE
1836
- or abs(dy) > ZOMBIE_SEPARATION_DISTANCE
1837
- ):
1766
+ if abs(dx) > ZOMBIE_SEPARATION_DISTANCE or abs(dy) > ZOMBIE_SEPARATION_DISTANCE:
1838
1767
  continue
1839
1768
  dist_sq = dx * dx + dy * dy
1840
1769
  if dist_sq < closest_dist_sq:
@@ -1896,9 +1825,7 @@ class Zombie(pygame.sprite.Sprite):
1896
1825
  def _apply_render_overlays(self: Self) -> None:
1897
1826
  base_surface = self.directional_images[self.facing_bin]
1898
1827
  needs_overlay = self.tracker or (
1899
- self.wall_hugging
1900
- and self.wall_hug_side != 0
1901
- and self.wall_hug_last_side_has_wall
1828
+ self.wall_hugging and self.wall_hug_side != 0 and self.wall_hug_last_side_has_wall
1902
1829
  )
1903
1830
  if not needs_overlay:
1904
1831
  self.image = base_surface
@@ -1912,11 +1839,7 @@ class Zombie(pygame.sprite.Sprite):
1912
1839
  angle_rad=angle_rad,
1913
1840
  color=ZOMBIE_NOSE_COLOR,
1914
1841
  )
1915
- if (
1916
- self.wall_hugging
1917
- and self.wall_hug_side != 0
1918
- and self.wall_hug_last_side_has_wall
1919
- ):
1842
+ if self.wall_hugging and self.wall_hug_side != 0 and self.wall_hug_last_side_has_wall:
1920
1843
  side_sign = 1.0 if self.wall_hug_side > 0 else -1.0
1921
1844
  hand_angle = angle_rad + side_sign * (math.pi / 2.0)
1922
1845
  draw_humanoid_hand(
@@ -1931,9 +1854,7 @@ class Zombie(pygame.sprite.Sprite):
1931
1854
  history.append((self.x, self.y))
1932
1855
  if len(history) > 20:
1933
1856
  history.pop(0)
1934
- max_dist_sq = max(
1935
- (self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history
1936
- )
1857
+ max_dist_sq = max((self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history)
1937
1858
  self.wall_hug_stuck_flag = max_dist_sq < 25
1938
1859
  if not self.wall_hug_stuck_flag:
1939
1860
  return
@@ -1993,8 +1914,7 @@ class Zombie(pygame.sprite.Sprite):
1993
1914
  outer_wall_cells: set[tuple[int, int]] | None = None,
1994
1915
  wall_cells: set[tuple[int, int]] | None = None,
1995
1916
  pitfall_cells: set[tuple[int, int]] | None = None,
1996
- bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
1997
- | None = None,
1917
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] | None = None,
1998
1918
  ) -> None:
1999
1919
  if self.carbonized:
2000
1920
  return
@@ -2015,11 +1935,8 @@ class Zombie(pygame.sprite.Sprite):
2015
1935
  grid_cols,
2016
1936
  grid_rows,
2017
1937
  outer_wall_cells,
1938
+ pitfall_cells,
2018
1939
  )
2019
- if pitfall_cells is not None:
2020
- avoid_x, avoid_y = self._avoid_pitfalls(pitfall_cells, cell_size)
2021
- move_x += avoid_x
2022
- move_y += avoid_y
2023
1940
  if dist_to_player_sq <= avoid_radius_sq or self.wall_hugging:
2024
1941
  move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
2025
1942
  if wall_cells is not None:
@@ -2038,9 +1955,7 @@ class Zombie(pygame.sprite.Sprite):
2038
1955
  self._apply_render_overlays()
2039
1956
  self.last_move_dx = move_x
2040
1957
  self.last_move_dy = move_y
2041
- final_x, final_y = self._handle_wall_collision(
2042
- self.x + move_x, self.y + move_y, walls
2043
- )
1958
+ final_x, final_y = self._handle_wall_collision(self.x + move_x, self.y + move_y, walls)
2044
1959
 
2045
1960
  if not (0 <= final_x < level_width and 0 <= final_y < level_height):
2046
1961
  self.kill()
@@ -2146,10 +2061,7 @@ class Car(pygame.sprite.Sprite):
2146
2061
  possible_walls = list(walls)
2147
2062
  else:
2148
2063
  possible_walls = [
2149
- w
2150
- for w in walls
2151
- if abs(w.rect.centery - self.y) < 100
2152
- and abs(w.rect.centerx - new_x) < 100
2064
+ w for w in walls if abs(w.rect.centery - self.y) < 100 and abs(w.rect.centerx - new_x) < 100
2153
2065
  ]
2154
2066
  car_center = (new_x, new_y)
2155
2067
  for wall in possible_walls:
@@ -2165,10 +2077,7 @@ class Car(pygame.sprite.Sprite):
2165
2077
  if hit_walls or in_pitfall:
2166
2078
  if hit_walls:
2167
2079
  self._take_damage(CAR_WALL_DAMAGE)
2168
- hit_walls.sort(
2169
- key=lambda w: (w.rect.centery - self.y) ** 2
2170
- + (w.rect.centerx - self.x) ** 2
2171
- )
2080
+ hit_walls.sort(key=lambda w: (w.rect.centery - self.y) ** 2 + (w.rect.centerx - self.x) ** 2)
2172
2081
  nearest_wall = hit_walls[0]
2173
2082
  new_x += (self.x - nearest_wall.rect.centerx) * 1.2
2174
2083
  new_y += (self.y - nearest_wall.rect.centery) * 1.2