zombie-escape 1.14.4__py3-none-any.whl → 1.15.2__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 (42) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/config.py +1 -0
  3. zombie_escape/entities.py +126 -199
  4. zombie_escape/entities_constants.py +11 -1
  5. zombie_escape/export_images.py +4 -4
  6. zombie_escape/font_utils.py +47 -0
  7. zombie_escape/gameplay/__init__.py +2 -1
  8. zombie_escape/gameplay/constants.py +4 -0
  9. zombie_escape/gameplay/interactions.py +83 -16
  10. zombie_escape/gameplay/layout.py +9 -15
  11. zombie_escape/gameplay/movement.py +45 -29
  12. zombie_escape/gameplay/spawn.py +15 -29
  13. zombie_escape/gameplay/state.py +62 -7
  14. zombie_escape/gameplay/survivors.py +61 -10
  15. zombie_escape/gameplay/utils.py +33 -0
  16. zombie_escape/level_blueprints.py +35 -31
  17. zombie_escape/level_constants.py +2 -2
  18. zombie_escape/locales/ui.en.json +19 -8
  19. zombie_escape/locales/ui.ja.json +19 -8
  20. zombie_escape/localization.py +7 -1
  21. zombie_escape/models.py +21 -6
  22. zombie_escape/render/__init__.py +2 -2
  23. zombie_escape/render/core.py +113 -81
  24. zombie_escape/render/hud.py +112 -40
  25. zombie_escape/render/overview.py +93 -2
  26. zombie_escape/render/shadows.py +2 -2
  27. zombie_escape/render_constants.py +12 -0
  28. zombie_escape/screens/__init__.py +6 -189
  29. zombie_escape/screens/game_over.py +8 -21
  30. zombie_escape/screens/gameplay.py +71 -26
  31. zombie_escape/screens/settings.py +114 -43
  32. zombie_escape/screens/title.py +128 -47
  33. zombie_escape/stage_constants.py +37 -8
  34. zombie_escape/windowing.py +508 -0
  35. zombie_escape/world_grid.py +7 -5
  36. zombie_escape/zombie_escape.py +26 -13
  37. {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/METADATA +24 -24
  38. zombie_escape-1.15.2.dist-info/RECORD +54 -0
  39. zombie_escape-1.14.4.dist-info/RECORD +0 -53
  40. {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/WHEEL +0 -0
  41. {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/entry_points.txt +0 -0
  42. {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/licenses/LICENSE.txt +0 -0
@@ -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.14.4"
4
+ __version__ = "1.15.2"
zombie_escape/config.py CHANGED
@@ -11,6 +11,7 @@ APP_NAME = "ZombieEscape"
11
11
  DEFAULT_CONFIG: dict[str, Any] = {
12
12
  "language": "en",
13
13
  "footprints": {"enabled": True},
14
+ "visual": {"shadows": {"enabled": True}},
14
15
  "fast_zombies": {"enabled": False, "ratio": 0.1},
15
16
  "car_hint": {"enabled": True, "delay_ms": 180_000},
16
17
  "steel_beams": {"enabled": False, "chance": 0.05},
zombie_escape/entities.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import math
6
- from typing import Callable, Iterable, Sequence, cast
6
+ from typing import TYPE_CHECKING, Callable, Iterable, Sequence, cast
7
7
 
8
8
  try:
9
9
  from typing import Self
@@ -13,10 +13,14 @@ except ImportError: # pragma: no cover - Python 3.10 fallback
13
13
  import pygame
14
14
  from pygame import rect
15
15
 
16
+ if TYPE_CHECKING:
17
+ from .models import LevelLayout
18
+
16
19
  from .entities_constants import (
17
20
  BUDDY_FOLLOW_SPEED,
18
21
  BUDDY_RADIUS,
19
22
  BUDDY_WALL_DAMAGE,
23
+ BUDDY_WALL_DAMAGE_RANGE,
20
24
  CAR_HEALTH,
21
25
  CAR_HEIGHT,
22
26
  CAR_SPEED,
@@ -50,9 +54,6 @@ from .entities_constants import (
50
54
  ZOMBIE_SIGHT_RANGE,
51
55
  ZOMBIE_SPEED,
52
56
  ZOMBIE_TRACKER_FAR_SCENT_RADIUS,
53
- ZOMBIE_TRACKER_CROWD_BAND_LENGTH,
54
- ZOMBIE_TRACKER_CROWD_BAND_WIDTH,
55
- ZOMBIE_TRACKER_CROWD_COUNT,
56
57
  ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS,
57
58
  ZOMBIE_TRACKER_RELOCK_DELAY_MS,
58
59
  ZOMBIE_TRACKER_SCAN_INTERVAL_MS,
@@ -97,7 +98,7 @@ from .render_assets import (
97
98
  from .render_constants import ANGLE_BINS, ZOMBIE_NOSE_COLOR
98
99
  from .rng import get_rng
99
100
  from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
100
- from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
101
+ from .world_grid import WallIndex, apply_cell_edge_nudge, walls_for_radius
101
102
 
102
103
  RNG = get_rng()
103
104
 
@@ -335,9 +336,7 @@ MovementStrategy = Callable[
335
336
  Iterable["Zombie"],
336
337
  list[Footprint],
337
338
  int,
338
- int,
339
- int,
340
- set[tuple[int, int]] | None,
339
+ "LevelLayout",
341
340
  ],
342
341
  tuple[float, float],
343
342
  ]
@@ -669,16 +668,15 @@ class Player(pygame.sprite.Sprite):
669
668
  *,
670
669
  wall_index: WallIndex | None = None,
671
670
  cell_size: int | None = None,
672
- level_width: int | None = None,
673
- level_height: int | None = None,
674
- pitfall_cells: set[tuple[int, int]] | None = None,
675
- walkable_cells: list[tuple[int, int]] | None = None,
671
+ layout: LevelLayout,
676
672
  ) -> None:
677
673
  if self.in_car:
678
674
  return
679
675
 
680
- if level_width is None or level_height is None:
681
- raise ValueError("level_width/level_height are required for movement")
676
+ pitfall_cells = layout.pitfall_cells
677
+ walkable_cells = layout.walkable_cells
678
+ level_width = layout.field_rect.width
679
+ level_height = layout.field_rect.height
682
680
 
683
681
  now = pygame.time.get_ticks()
684
682
  if self.is_jumping:
@@ -882,18 +880,13 @@ class Survivor(pygame.sprite.Sprite):
882
880
  *,
883
881
  wall_index: WallIndex | None = None,
884
882
  cell_size: int | None = None,
885
- wall_cells: set[tuple[int, int]] | None = None,
886
- pitfall_cells: set[tuple[int, int]] | None = None,
887
- walkable_cells: list[tuple[int, int]] | None = None,
888
- bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] | None = None,
889
- grid_cols: int | None = None,
890
- grid_rows: int | None = None,
891
- level_width: int | None = None,
892
- level_height: int | None = None,
883
+ layout: LevelLayout,
893
884
  wall_target_cell: tuple[int, int] | None = None,
894
885
  ) -> None:
895
- if level_width is None or level_height is None:
896
- raise ValueError("level_width/level_height are required for movement")
886
+ pitfall_cells = layout.pitfall_cells
887
+ walkable_cells = layout.walkable_cells
888
+ level_width = layout.field_rect.width
889
+ level_height = layout.field_rect.height
897
890
 
898
891
  now = pygame.time.get_ticks()
899
892
  if self.is_jumping:
@@ -928,17 +921,14 @@ class Survivor(pygame.sprite.Sprite):
928
921
  move_x = (dx / dist) * BUDDY_FOLLOW_SPEED
929
922
  move_y = (dy / dist) * BUDDY_FOLLOW_SPEED
930
923
 
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:
932
- move_x, move_y = apply_tile_edge_nudge(
924
+ if cell_size is not None:
925
+ move_x, move_y = apply_cell_edge_nudge(
933
926
  self.x,
934
927
  self.y,
935
928
  move_x,
936
929
  move_y,
930
+ layout=layout,
937
931
  cell_size=cell_size,
938
- wall_cells=wall_cells,
939
- bevel_corners=bevel_corners,
940
- grid_cols=grid_cols,
941
- grid_rows=grid_rows,
942
932
  )
943
933
 
944
934
  self._update_input_facing(move_x, move_y)
@@ -986,7 +976,12 @@ class Survivor(pygame.sprite.Sprite):
986
976
  if hit_wall or blocked_by_pitfall:
987
977
  if hit_wall:
988
978
  if hit_wall.alive():
989
- hit_wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
979
+ dx_to_player = player_pos[0] - self.x
980
+ dy_to_player = player_pos[1] - self.y
981
+ if dx_to_player * dx_to_player + dy_to_player * dy_to_player <= (
982
+ BUDDY_WALL_DAMAGE_RANGE * BUDDY_WALL_DAMAGE_RANGE
983
+ ):
984
+ hit_wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
990
985
  if _is_inner_wall(hit_wall):
991
986
  inner_wall_hit = True
992
987
  self.x -= move_x
@@ -1017,7 +1012,12 @@ class Survivor(pygame.sprite.Sprite):
1017
1012
  if hit_wall or blocked_by_pitfall:
1018
1013
  if hit_wall:
1019
1014
  if hit_wall.alive():
1020
- hit_wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
1015
+ dx_to_player = player_pos[0] - self.x
1016
+ dy_to_player = player_pos[1] - self.y
1017
+ if dx_to_player * dx_to_player + dy_to_player * dy_to_player <= (
1018
+ BUDDY_WALL_DAMAGE_RANGE * BUDDY_WALL_DAMAGE_RANGE
1019
+ ):
1020
+ hit_wall._take_damage(amount=max(1, BUDDY_WALL_DAMAGE))
1021
1021
  if _is_inner_wall(hit_wall):
1022
1022
  inner_wall_hit = True
1023
1023
  self.y -= move_y
@@ -1069,17 +1069,14 @@ class Survivor(pygame.sprite.Sprite):
1069
1069
  )
1070
1070
  )
1071
1071
 
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:
1073
- move_x, move_y = apply_tile_edge_nudge(
1072
+ if cell_size is not None:
1073
+ move_x, move_y = apply_cell_edge_nudge(
1074
1074
  self.x,
1075
1075
  self.y,
1076
1076
  move_x,
1077
1077
  move_y,
1078
+ layout=layout,
1078
1079
  cell_size=cell_size,
1079
- wall_cells=wall_cells,
1080
- bevel_corners=bevel_corners,
1081
- grid_cols=grid_cols,
1082
- grid_rows=grid_rows,
1083
1080
  )
1084
1081
 
1085
1082
  if move_x:
@@ -1199,66 +1196,34 @@ def _zombie_tracker_movement(
1199
1196
  nearby_zombies: Iterable[Zombie],
1200
1197
  footprints: list[Footprint],
1201
1198
  cell_size: int,
1202
- grid_cols: int,
1203
- grid_rows: int,
1204
- outer_wall_cells: set[tuple[int, int]] | None,
1205
- pitfall_cells: set[tuple[int, int]] | None,
1199
+ layout: LevelLayout,
1206
1200
  ) -> tuple[float, float]:
1207
1201
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
1208
1202
  if not is_in_sight:
1209
- if _zombie_tracker_is_crowded(zombie, nearby_zombies):
1203
+ if zombie.tracker_force_wander:
1210
1204
  last_target_time = zombie.tracker_target_time
1211
1205
  if last_target_time is None:
1212
1206
  last_target_time = pygame.time.get_ticks()
1213
1207
  zombie.tracker_relock_after_time = last_target_time + ZOMBIE_TRACKER_RELOCK_DELAY_MS
1214
1208
  zombie.tracker_target_pos = None
1215
- return _zombie_wander_move(
1209
+ return _zombie_wander_movement(
1216
1210
  zombie,
1217
1211
  walls,
1218
1212
  cell_size=cell_size,
1219
- grid_cols=grid_cols,
1220
- grid_rows=grid_rows,
1221
- outer_wall_cells=outer_wall_cells,
1222
- pitfall_cells=pitfall_cells,
1213
+ layout=layout,
1223
1214
  )
1224
1215
  _zombie_update_tracker_target(zombie, footprints, walls)
1225
1216
  if zombie.tracker_target_pos is not None:
1226
1217
  return _zombie_move_toward(zombie, zombie.tracker_target_pos)
1227
- return _zombie_wander_move(
1218
+ return _zombie_wander_movement(
1228
1219
  zombie,
1229
1220
  walls,
1230
1221
  cell_size=cell_size,
1231
- grid_cols=grid_cols,
1232
- grid_rows=grid_rows,
1233
- outer_wall_cells=outer_wall_cells,
1234
- pitfall_cells=pitfall_cells,
1222
+ layout=layout,
1235
1223
  )
1236
1224
  return _zombie_move_toward(zombie, player_center)
1237
1225
 
1238
1226
 
1239
- def _zombie_wander_movement(
1240
- zombie: Zombie,
1241
- _player_center: tuple[int, int],
1242
- walls: list[Wall],
1243
- _nearby_zombies: Iterable[Zombie],
1244
- _footprints: list[Footprint],
1245
- cell_size: int,
1246
- grid_cols: int,
1247
- grid_rows: int,
1248
- outer_wall_cells: set[tuple[int, int]] | None,
1249
- pitfall_cells: set[tuple[int, int]] | None,
1250
- ) -> tuple[float, float]:
1251
- return _zombie_wander_move(
1252
- zombie,
1253
- walls,
1254
- cell_size=cell_size,
1255
- grid_cols=grid_cols,
1256
- grid_rows=grid_rows,
1257
- outer_wall_cells=outer_wall_cells,
1258
- pitfall_cells=pitfall_cells,
1259
- )
1260
-
1261
-
1262
1227
  def _zombie_wall_hug_has_wall(
1263
1228
  zombie: Zombie,
1264
1229
  walls: list[Wall],
@@ -1308,10 +1273,7 @@ def _zombie_wall_hug_movement(
1308
1273
  _nearby_zombies: Iterable[Zombie],
1309
1274
  _footprints: list[Footprint],
1310
1275
  cell_size: int,
1311
- grid_cols: int,
1312
- grid_rows: int,
1313
- outer_wall_cells: set[tuple[int, int]] | None,
1314
- pitfall_cells: set[tuple[int, int]] | None,
1276
+ layout: LevelLayout,
1315
1277
  ) -> tuple[float, float]:
1316
1278
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
1317
1279
  if zombie.wall_hug_angle is None:
@@ -1342,14 +1304,11 @@ def _zombie_wall_hug_movement(
1342
1304
  else:
1343
1305
  if is_in_sight:
1344
1306
  return _zombie_move_toward(zombie, player_center)
1345
- return _zombie_wander_move(
1307
+ return _zombie_wander_movement(
1346
1308
  zombie,
1347
1309
  walls,
1348
1310
  cell_size=cell_size,
1349
- grid_cols=grid_cols,
1350
- grid_rows=grid_rows,
1351
- outer_wall_cells=outer_wall_cells,
1352
- pitfall_cells=pitfall_cells,
1311
+ layout=layout,
1353
1312
  )
1354
1313
 
1355
1314
  sensor_distance = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius
@@ -1405,21 +1364,15 @@ def _zombie_normal_movement(
1405
1364
  _nearby_zombies: Iterable[Zombie],
1406
1365
  _footprints: list[Footprint],
1407
1366
  cell_size: int,
1408
- grid_cols: int,
1409
- grid_rows: int,
1410
- outer_wall_cells: set[tuple[int, int]] | None,
1411
- pitfall_cells: set[tuple[int, int]] | None,
1367
+ layout: LevelLayout,
1412
1368
  ) -> tuple[float, float]:
1413
1369
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_SIGHT_RANGE)
1414
1370
  if not is_in_sight:
1415
- return _zombie_wander_move(
1371
+ return _zombie_wander_movement(
1416
1372
  zombie,
1417
1373
  walls,
1418
1374
  cell_size=cell_size,
1419
- grid_cols=grid_cols,
1420
- grid_rows=grid_rows,
1421
- outer_wall_cells=outer_wall_cells,
1422
- pitfall_cells=pitfall_cells,
1375
+ layout=layout,
1423
1376
  )
1424
1377
  return _zombie_move_toward(zombie, player_center)
1425
1378
 
@@ -1429,6 +1382,7 @@ def _zombie_update_tracker_target(
1429
1382
  footprints: list[Footprint],
1430
1383
  walls: list[Wall],
1431
1384
  ) -> None:
1385
+ # footprints are ordered oldest -> newest by time.
1432
1386
  now = pygame.time.get_ticks()
1433
1387
  if now - zombie.tracker_last_scan_time < zombie.tracker_scan_interval_ms:
1434
1388
  return
@@ -1436,43 +1390,42 @@ def _zombie_update_tracker_target(
1436
1390
  if not footprints:
1437
1391
  zombie.tracker_target_pos = None
1438
1392
  return
1439
- nearby: list[Footprint] = []
1440
1393
  last_target_time = zombie.tracker_target_time
1441
1394
  far_radius_sq = ZOMBIE_TRACKER_FAR_SCENT_RADIUS * ZOMBIE_TRACKER_FAR_SCENT_RADIUS
1442
- latest_fp_time: int | None = None
1443
1395
  relock_after = zombie.tracker_relock_after_time
1396
+ far_candidates: list[tuple[float, Footprint]] = []
1444
1397
  for fp in footprints:
1445
1398
  dx = fp.pos[0] - zombie.x
1446
1399
  dy = fp.pos[1] - zombie.y
1447
- if dx * dx + dy * dy <= far_radius_sq:
1448
- if latest_fp_time is None or fp.time > latest_fp_time:
1449
- latest_fp_time = fp.time
1400
+ d2 = dx * dx + dy * dy
1401
+ if d2 <= far_radius_sq:
1402
+ far_candidates.append((d2, fp))
1403
+ if not far_candidates:
1404
+ return
1405
+ latest_fp_time = far_candidates[-1][1].time
1450
1406
  use_far_scan = last_target_time is None or (
1451
1407
  latest_fp_time is not None and latest_fp_time - last_target_time >= ZOMBIE_TRACKER_NEWER_FOOTPRINT_MS
1452
1408
  )
1453
1409
  scan_radius = ZOMBIE_TRACKER_FAR_SCENT_RADIUS if use_far_scan else ZOMBIE_TRACKER_SCENT_RADIUS
1454
1410
  scent_radius_sq = scan_radius * scan_radius
1455
1411
  min_target_dist_sq = (FOOTPRINT_STEP_DISTANCE * 0.5) ** 2
1456
- for fp in footprints:
1412
+
1413
+ newer: list[Footprint] = []
1414
+ for d2, fp in far_candidates:
1457
1415
  pos = fp.pos
1458
1416
  fp_time = fp.time
1459
1417
  if relock_after is not None and fp_time < relock_after:
1460
1418
  continue
1461
- dx = pos[0] - zombie.x
1462
- dy = pos[1] - zombie.y
1463
- if dx * dx + dy * dy <= min_target_dist_sq:
1419
+ if d2 <= min_target_dist_sq:
1464
1420
  continue
1465
- if dx * dx + dy * dy <= scent_radius_sq:
1466
- nearby.append(fp)
1421
+ if d2 <= scent_radius_sq:
1422
+ if last_target_time is None or fp_time > last_target_time:
1423
+ newer.append(fp)
1467
1424
 
1468
- if not nearby:
1425
+ if not newer:
1469
1426
  return
1470
1427
 
1471
- nearby.sort(key=lambda fp: fp.time)
1472
- if last_target_time is not None:
1473
- newer = [fp for fp in nearby if fp.time > last_target_time]
1474
- else:
1475
- newer = nearby
1428
+ newer.sort(key=lambda fp: fp.time)
1476
1429
 
1477
1430
  if use_far_scan or last_target_time is None:
1478
1431
  candidates = list(reversed(newer))[:ZOMBIE_TRACKER_SCENT_TOP_K]
@@ -1504,11 +1457,7 @@ def _zombie_update_tracker_target(
1504
1457
  if last_target_time is None:
1505
1458
  return
1506
1459
 
1507
- candidates = [fp for fp in nearby if fp.time > last_target_time]
1508
- if not candidates:
1509
- return
1510
- candidates.sort(key=lambda fp: fp.time)
1511
- next_fp = candidates[0]
1460
+ next_fp = newer[0]
1512
1461
  zombie.tracker_target_pos = next_fp.pos
1513
1462
  zombie.tracker_target_time = next_fp.time
1514
1463
  if relock_after is not None and next_fp.time >= relock_after:
@@ -1516,50 +1465,17 @@ def _zombie_update_tracker_target(
1516
1465
  return
1517
1466
 
1518
1467
 
1519
- def _zombie_tracker_is_crowded(
1520
- zombie: Zombie,
1521
- nearby_zombies: Iterable[Zombie],
1522
- ) -> bool:
1523
- dx = zombie.last_move_dx
1524
- dy = zombie.last_move_dy
1525
- if abs(dx) <= 0.001 and abs(dy) <= 0.001:
1526
- return False
1527
- angle = math.atan2(dy, dx)
1528
- angle_step = math.pi / 4.0
1529
- angle_bin = int(round(angle / angle_step)) % 8
1530
- dir_x = math.cos(angle_bin * angle_step)
1531
- dir_y = math.sin(angle_bin * angle_step)
1532
- perp_x = -dir_y
1533
- perp_y = dir_x
1534
- half_width = ZOMBIE_TRACKER_CROWD_BAND_WIDTH * 0.5
1535
- length = ZOMBIE_TRACKER_CROWD_BAND_LENGTH
1536
- count = 0
1537
- for other in nearby_zombies:
1538
- if other is zombie or not other.alive() or not other.tracker:
1539
- continue
1540
- offset_x = other.x - zombie.x
1541
- offset_y = other.y - zombie.y
1542
- forward = offset_x * dir_x + offset_y * dir_y
1543
- if forward <= 0 or forward > length:
1544
- continue
1545
- side = offset_x * perp_x + offset_y * perp_y
1546
- if abs(side) <= half_width:
1547
- count += 1
1548
- if count >= ZOMBIE_TRACKER_CROWD_COUNT:
1549
- return True
1550
- return False
1551
-
1552
-
1553
- def _zombie_wander_move(
1468
+ def _zombie_wander_movement(
1554
1469
  zombie: Zombie,
1555
1470
  walls: list[Wall],
1556
1471
  *,
1557
1472
  cell_size: int,
1558
- grid_cols: int,
1559
- grid_rows: int,
1560
- outer_wall_cells: set[tuple[int, int]] | None,
1561
- pitfall_cells: set[tuple[int, int]] | None,
1473
+ layout: LevelLayout,
1562
1474
  ) -> tuple[float, float]:
1475
+ grid_cols = layout.grid_cols
1476
+ grid_rows = layout.grid_rows
1477
+ outer_wall_cells = layout.outer_wall_cells
1478
+ pitfall_cells = layout.pitfall_cells
1563
1479
  now = pygame.time.get_ticks()
1564
1480
  changed_angle = False
1565
1481
  if now - zombie.last_wander_change_time > zombie.wander_change_interval:
@@ -1700,12 +1616,12 @@ class Zombie(pygame.sprite.Sprite):
1700
1616
  self.tracker_last_scan_time = 0
1701
1617
  self.tracker_scan_interval_ms = ZOMBIE_TRACKER_SCAN_INTERVAL_MS
1702
1618
  self.tracker_relock_after_time: int | None = None
1619
+ self.tracker_force_wander = False
1703
1620
  self.wall_hug_side = RNG.choice([-1.0, 1.0]) if wall_hugging else 0.0
1704
1621
  self.wall_hug_angle = RNG.uniform(0, math.tau) if wall_hugging else None
1705
1622
  self.wall_hug_last_wall_time: int | None = None
1706
1623
  self.wall_hug_last_side_has_wall = False
1707
1624
  self.wall_hug_stuck_flag = False
1708
- self.pos_history: list[tuple[float, float]] = []
1709
1625
  self.wander_angle = RNG.uniform(0, math.tau)
1710
1626
  self.wander_interval_ms = ZOMBIE_TRACKER_WANDER_INTERVAL_MS if tracker else ZOMBIE_WANDER_INTERVAL_MS
1711
1627
  self.last_wander_change_time = pygame.time.get_ticks()
@@ -1721,6 +1637,13 @@ class Zombie(pygame.sprite.Sprite):
1721
1637
  self.was_in_sight = is_in_sight
1722
1638
  return is_in_sight
1723
1639
 
1640
+ def _apply_wall_contact_damage(self: Self, walls: list[Wall]) -> None:
1641
+ possible_walls = [w for w in walls if abs(w.rect.centerx - self.x) < 100 and abs(w.rect.centery - self.y) < 100]
1642
+ for wall in possible_walls:
1643
+ if _circle_wall_collision((self.x, self.y), self.radius, wall):
1644
+ if wall.alive():
1645
+ wall._take_damage(amount=ZOMBIE_WALL_DAMAGE)
1646
+
1724
1647
  def _handle_wall_collision(self: Self, next_x: float, next_y: float, walls: list[Wall]) -> tuple[float, float]:
1725
1648
  final_x, final_y = next_x, next_y
1726
1649
 
@@ -1753,6 +1676,7 @@ class Zombie(pygame.sprite.Sprite):
1753
1676
  zombies: Iterable[Zombie],
1754
1677
  ) -> tuple[float, float]:
1755
1678
  """If another zombie is too close, steer directly away from the closest one."""
1679
+ orig_move_x, orig_move_y = move_x, move_y
1756
1680
  next_x = self.x + move_x
1757
1681
  next_y = self.y + move_y
1758
1682
 
@@ -1796,6 +1720,16 @@ class Zombie(pygame.sprite.Sprite):
1796
1720
 
1797
1721
  move_x = (away_dx / away_dist) * self.speed
1798
1722
  move_y = (away_dy / away_dist) * self.speed
1723
+ if self.wall_hugging:
1724
+ if orig_move_x or orig_move_y:
1725
+ orig_angle = math.atan2(orig_move_y, orig_move_x)
1726
+ new_angle = math.atan2(move_y, move_x)
1727
+ diff = (new_angle - orig_angle + math.pi) % math.tau - math.pi
1728
+ if abs(diff) > math.pi / 2.0:
1729
+ clamped = math.copysign(math.pi / 2.0, diff)
1730
+ new_angle = orig_angle + clamped
1731
+ move_x = math.cos(new_angle) * self.speed
1732
+ move_y = math.sin(new_angle) * self.speed
1799
1733
  return move_x, move_y
1800
1734
 
1801
1735
  def _apply_aging(self: Self) -> None:
@@ -1849,23 +1783,6 @@ class Zombie(pygame.sprite.Sprite):
1849
1783
  color=ZOMBIE_NOSE_COLOR,
1850
1784
  )
1851
1785
 
1852
- def _update_stuck_state(self: Self) -> None:
1853
- history = self.pos_history
1854
- history.append((self.x, self.y))
1855
- if len(history) > 20:
1856
- history.pop(0)
1857
- max_dist_sq = max((self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history)
1858
- self.wall_hug_stuck_flag = max_dist_sq < 25
1859
- if not self.wall_hug_stuck_flag:
1860
- return
1861
- if self.wall_hugging:
1862
- if self.wall_hug_angle is None:
1863
- self.wall_hug_angle = self.wander_angle
1864
- self.wall_hug_angle = (self.wall_hug_angle + math.pi) % math.tau
1865
- self.wall_hug_side *= -1.0
1866
- self.wall_hug_stuck_flag = False
1867
- self.pos_history = []
1868
-
1869
1786
  def _avoid_pitfalls(
1870
1787
  self: Self,
1871
1788
  pitfall_cells: set[tuple[int, int]],
@@ -1907,18 +1824,13 @@ class Zombie(pygame.sprite.Sprite):
1907
1824
  footprints: list[Footprint] | None = None,
1908
1825
  *,
1909
1826
  cell_size: int,
1910
- grid_cols: int,
1911
- grid_rows: int,
1912
- level_width: int,
1913
- level_height: int,
1914
- outer_wall_cells: set[tuple[int, int]] | None = None,
1915
- wall_cells: set[tuple[int, int]] | None = None,
1916
- pitfall_cells: set[tuple[int, int]] | None = None,
1917
- bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] | None = None,
1827
+ layout: LevelLayout,
1918
1828
  ) -> None:
1919
1829
  if self.carbonized:
1920
1830
  return
1921
- self._update_stuck_state()
1831
+ level_width = layout.field_rect.width
1832
+ level_height = layout.field_rect.height
1833
+ self._apply_wall_contact_damage(walls)
1922
1834
  self._apply_aging()
1923
1835
  dx_player = player_center[0] - self.x
1924
1836
  dy_player = player_center[1] - self.y
@@ -1932,30 +1844,45 @@ class Zombie(pygame.sprite.Sprite):
1932
1844
  nearby_zombies,
1933
1845
  footprints or [],
1934
1846
  cell_size,
1935
- grid_cols,
1936
- grid_rows,
1937
- outer_wall_cells,
1938
- pitfall_cells,
1847
+ layout,
1939
1848
  )
1940
1849
  if dist_to_player_sq <= avoid_radius_sq or self.wall_hugging:
1941
1850
  move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
1942
- if wall_cells is not None:
1943
- move_x, move_y = apply_tile_edge_nudge(
1944
- self.x,
1945
- self.y,
1946
- move_x,
1947
- move_y,
1948
- cell_size=cell_size,
1949
- wall_cells=wall_cells,
1950
- bevel_corners=bevel_corners,
1951
- grid_cols=grid_cols,
1952
- grid_rows=grid_rows,
1953
- )
1851
+ move_x, move_y = apply_cell_edge_nudge(
1852
+ self.x,
1853
+ self.y,
1854
+ move_x,
1855
+ move_y,
1856
+ layout=layout,
1857
+ cell_size=cell_size,
1858
+ )
1954
1859
  self._update_facing_from_movement(move_x, move_y)
1955
1860
  self._apply_render_overlays()
1956
1861
  self.last_move_dx = move_x
1957
1862
  self.last_move_dy = move_y
1958
- final_x, final_y = self._handle_wall_collision(self.x + move_x, self.y + move_y, walls)
1863
+ possible_walls = [w for w in walls if abs(w.rect.centerx - self.x) < 100 and abs(w.rect.centery - self.y) < 100]
1864
+ final_x = self.x
1865
+ final_y = self.y
1866
+ if move_x:
1867
+ next_x = final_x + move_x
1868
+ for wall in possible_walls:
1869
+ if _circle_wall_collision((next_x, final_y), self.radius, wall):
1870
+ if wall.alive():
1871
+ wall._take_damage(amount=ZOMBIE_WALL_DAMAGE)
1872
+ if wall.alive():
1873
+ next_x = final_x
1874
+ break
1875
+ final_x = next_x
1876
+ if move_y:
1877
+ next_y = final_y + move_y
1878
+ for wall in possible_walls:
1879
+ if _circle_wall_collision((final_x, next_y), self.radius, wall):
1880
+ if wall.alive():
1881
+ wall._take_damage(amount=ZOMBIE_WALL_DAMAGE)
1882
+ if wall.alive():
1883
+ next_y = final_y
1884
+ break
1885
+ final_y = next_y
1959
1886
 
1960
1887
  if not (0 <= final_x < level_width and 0 <= final_y < level_height):
1961
1888
  self.kill()
@@ -11,10 +11,14 @@ 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
+ BUDDY_FOLLOW_START_DISTANCE = 30
15
+ BUDDY_FOLLOW_STOP_DISTANCE = 100
16
+ BUDDY_WALL_DAMAGE_RANGE = 50
17
+ BUDDY_MERGE_DISTANCE = 40
14
18
  HUMANOID_WALL_BUMP_FRAMES = 7
15
19
 
16
20
  # --- Jump settings ---
17
- PLAYER_JUMP_RANGE = 15 # px (enough to clear one 50px tile)
21
+ PLAYER_JUMP_RANGE = 15 # px (enough to clear one 50px cell)
18
22
  SURVIVOR_JUMP_RANGE = int(PLAYER_JUMP_RANGE * 0.7) # px
19
23
  JUMP_DURATION_MS = 200
20
24
  JUMP_SCALE_MAX = 0.15
@@ -54,6 +58,7 @@ ZOMBIE_TRACKER_WANDER_INTERVAL_MS = 2500
54
58
  ZOMBIE_TRACKER_CROWD_BAND_WIDTH = 50
55
59
  ZOMBIE_TRACKER_CROWD_BAND_LENGTH = 50
56
60
  ZOMBIE_TRACKER_CROWD_COUNT = 5
61
+ ZOMBIE_TRACKER_GRID_CROWD_COUNT = 3
57
62
  ZOMBIE_TRACKER_RELOCK_DELAY_MS = 3000
58
63
  ZOMBIE_WALL_HUG_SENSOR_DISTANCE = 24
59
64
  ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG = 45
@@ -85,6 +90,10 @@ __all__ = [
85
90
  "FOV_RADIUS",
86
91
  "BUDDY_RADIUS",
87
92
  "BUDDY_FOLLOW_SPEED",
93
+ "BUDDY_FOLLOW_START_DISTANCE",
94
+ "BUDDY_FOLLOW_STOP_DISTANCE",
95
+ "BUDDY_WALL_DAMAGE_RANGE",
96
+ "BUDDY_MERGE_DISTANCE",
88
97
  "HUMANOID_WALL_BUMP_FRAMES",
89
98
  "SURVIVOR_RADIUS",
90
99
  "SURVIVOR_APPROACH_RADIUS",
@@ -113,6 +122,7 @@ __all__ = [
113
122
  "ZOMBIE_TRACKER_CROWD_BAND_WIDTH",
114
123
  "ZOMBIE_TRACKER_CROWD_BAND_LENGTH",
115
124
  "ZOMBIE_TRACKER_CROWD_COUNT",
125
+ "ZOMBIE_TRACKER_GRID_CROWD_COUNT",
116
126
  "ZOMBIE_TRACKER_RELOCK_DELAY_MS",
117
127
  "ZOMBIE_WALL_HUG_SENSOR_DISTANCE",
118
128
  "ZOMBIE_WALL_HUG_PROBE_ANGLE_DEG",
@@ -22,7 +22,7 @@ from .entities_constants import (
22
22
  SURVIVOR_RADIUS,
23
23
  ZOMBIE_RADIUS,
24
24
  )
25
- from .level_constants import DEFAULT_TILE_SIZE
25
+ from .level_constants import DEFAULT_CELL_SIZE
26
26
  from .render_assets import (
27
27
  build_car_directional_surfaces,
28
28
  build_car_surface,
@@ -81,7 +81,7 @@ def _pick_directional_surface(surfaces: list[pygame.Surface], *, bin_index: int
81
81
  return surfaces[bin_index % len(surfaces)]
82
82
 
83
83
 
84
- def _build_pitfall_tile(cell_size: int) -> pygame.Surface:
84
+ def _build_pitfall_cell(cell_size: int) -> pygame.Surface:
85
85
  surface = pygame.Surface((cell_size, cell_size), pygame.SRCALPHA)
86
86
  rect = surface.get_rect()
87
87
  pygame.draw.rect(surface, PITFALL_ABYSS_COLOR, rect)
@@ -116,7 +116,7 @@ def _build_pitfall_tile(cell_size: int) -> pygame.Surface:
116
116
  return surface
117
117
 
118
118
 
119
- def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE, output_scale: int = 4) -> list[Path]:
119
+ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_CELL_SIZE, output_scale: int = 4) -> list[Path]:
120
120
  _ensure_pygame_ready()
121
121
 
122
122
  saved: list[Path] = []
@@ -267,7 +267,7 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE, outpu
267
267
  _save_surface(outer_wall, outer_wall_path, scale=output_scale)
268
268
  saved.append(outer_wall_path)
269
269
 
270
- pitfall = _build_pitfall_tile(cell_size)
270
+ pitfall = _build_pitfall_cell(cell_size)
271
271
  pitfall_path = out / "pitfall.png"
272
272
  _save_surface(pitfall, pitfall_path, scale=output_scale)
273
273
  saved.append(pitfall_path)