zombie-escape 1.7.1__py3-none-any.whl → 1.10.0__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 (36) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/colors.py +41 -8
  3. zombie_escape/entities.py +376 -306
  4. zombie_escape/entities_constants.py +6 -0
  5. zombie_escape/gameplay/__init__.py +2 -2
  6. zombie_escape/gameplay/constants.py +1 -7
  7. zombie_escape/gameplay/footprints.py +2 -2
  8. zombie_escape/gameplay/interactions.py +4 -10
  9. zombie_escape/gameplay/layout.py +43 -4
  10. zombie_escape/gameplay/movement.py +45 -7
  11. zombie_escape/gameplay/spawn.py +283 -43
  12. zombie_escape/gameplay/state.py +19 -16
  13. zombie_escape/gameplay/survivors.py +47 -15
  14. zombie_escape/gameplay/utils.py +19 -1
  15. zombie_escape/input_utils.py +167 -0
  16. zombie_escape/level_blueprints.py +28 -0
  17. zombie_escape/locales/ui.en.json +55 -11
  18. zombie_escape/locales/ui.ja.json +54 -10
  19. zombie_escape/localization.py +28 -0
  20. zombie_escape/models.py +54 -7
  21. zombie_escape/render.py +704 -267
  22. zombie_escape/render_constants.py +12 -0
  23. zombie_escape/screens/__init__.py +1 -0
  24. zombie_escape/screens/game_over.py +8 -4
  25. zombie_escape/screens/gameplay.py +88 -41
  26. zombie_escape/screens/settings.py +124 -13
  27. zombie_escape/screens/title.py +111 -0
  28. zombie_escape/stage_constants.py +116 -3
  29. zombie_escape/world_grid.py +134 -0
  30. zombie_escape/zombie_escape.py +68 -61
  31. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/METADATA +11 -3
  32. zombie_escape-1.10.0.dist-info/RECORD +47 -0
  33. zombie_escape-1.7.1.dist-info/RECORD +0 -45
  34. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/WHEEL +0 -0
  35. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/entry_points.txt +0 -0
  36. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/entities.py CHANGED
@@ -3,7 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import math
6
- from typing import Callable, Iterable, Self, Sequence
6
+ from typing import Callable, Iterable, Sequence, cast
7
+
8
+ try:
9
+ from typing import Self
10
+ except ImportError: # pragma: no cover - Python 3.10 fallback
11
+ from typing_extensions import Self
7
12
 
8
13
  import pygame
9
14
  from pygame import rect
@@ -36,7 +41,10 @@ from .entities_constants import (
36
41
  ZOMBIE_SEPARATION_DISTANCE,
37
42
  ZOMBIE_SIGHT_RANGE,
38
43
  ZOMBIE_SPEED,
44
+ ZOMBIE_TRACKER_SCAN_INTERVAL_MS,
45
+ ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER,
39
46
  ZOMBIE_TRACKER_SCENT_RADIUS,
47
+ ZOMBIE_TRACKER_SCENT_TOP_K,
40
48
  ZOMBIE_TRACKER_SIGHT_RANGE,
41
49
  ZOMBIE_TRACKER_WANDER_INTERVAL_MS,
42
50
  ZOMBIE_WALL_DAMAGE,
@@ -47,6 +55,8 @@ from .entities_constants import (
47
55
  ZOMBIE_WALL_FOLLOW_TARGET_GAP,
48
56
  ZOMBIE_WANDER_INTERVAL_MS,
49
57
  )
58
+ from .gameplay.constants import FOOTPRINT_STEP_DISTANCE
59
+ from .models import Footprint
50
60
  from .render_assets import (
51
61
  EnvironmentPalette,
52
62
  build_beveled_polygon,
@@ -66,15 +76,162 @@ from .render_assets import (
66
76
  )
67
77
  from .rng import get_rng
68
78
  from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
79
+ from .world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
69
80
 
70
81
  RNG = get_rng()
71
82
 
83
+
84
+ class Wall(pygame.sprite.Sprite):
85
+ def __init__(
86
+ self: Self,
87
+ x: int,
88
+ y: int,
89
+ width: int,
90
+ height: int,
91
+ *,
92
+ health: int = INTERNAL_WALL_HEALTH,
93
+ palette: EnvironmentPalette | None = None,
94
+ palette_category: str = "inner_wall",
95
+ bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
96
+ bevel_mask: tuple[bool, bool, bool, bool] | None = None,
97
+ draw_bottom_side: bool = False,
98
+ bottom_side_ratio: float = 0.1,
99
+ side_shade_ratio: float = 0.9,
100
+ on_destroy: Callable[[Self], None] | None = None,
101
+ ) -> None:
102
+ super().__init__()
103
+ safe_width = max(1, width)
104
+ safe_height = max(1, height)
105
+ self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
106
+ self.palette = palette
107
+ self.palette_category = palette_category
108
+ self.health = health
109
+ self.max_health = max(1, health)
110
+ self.on_destroy = on_destroy
111
+ self.bevel_depth = max(0, bevel_depth)
112
+ self.bevel_mask = bevel_mask or (False, False, False, False)
113
+ self.draw_bottom_side = draw_bottom_side
114
+ self.bottom_side_ratio = max(0.0, bottom_side_ratio)
115
+ self.side_shade_ratio = max(0.0, min(1.0, side_shade_ratio))
116
+ self._local_polygon = _build_beveled_polygon(
117
+ safe_width, safe_height, self.bevel_depth, self.bevel_mask
118
+ )
119
+ self._update_color()
120
+ self.rect = self.image.get_rect(topleft=(x, y))
121
+ # Keep collision rectangular even when beveled visually.
122
+ self._collision_polygon = None
123
+
124
+ def _take_damage(self: Self, *, amount: int = 1) -> None:
125
+ if self.health > 0:
126
+ self.health -= amount
127
+ self._update_color()
128
+ if self.health <= 0:
129
+ if self.on_destroy:
130
+ try:
131
+ self.on_destroy(self)
132
+ except Exception as exc:
133
+ print(f"Wall destroy callback failed: {exc}")
134
+ self.kill()
135
+
136
+ def _update_color(self: Self) -> None:
137
+ if self.health <= 0:
138
+ health_ratio = 0.0
139
+ else:
140
+ health_ratio = max(0.0, self.health / self.max_health)
141
+ fill_color, border_color = resolve_wall_colors(
142
+ health_ratio=health_ratio,
143
+ palette_category=self.palette_category,
144
+ palette=self.palette,
145
+ )
146
+ paint_wall_surface(
147
+ self.image,
148
+ fill_color=fill_color,
149
+ border_color=border_color,
150
+ bevel_depth=self.bevel_depth,
151
+ bevel_mask=self.bevel_mask,
152
+ draw_bottom_side=self.draw_bottom_side,
153
+ bottom_side_ratio=self.bottom_side_ratio,
154
+ side_shade_ratio=self.side_shade_ratio,
155
+ )
156
+
157
+ def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
158
+ if self._collision_polygon is None:
159
+ return self.rect.colliderect(rect_obj)
160
+ return _rect_polygon_collision(rect_obj, self._collision_polygon)
161
+
162
+ def _collides_circle(
163
+ self: Self, center: tuple[float, float], radius: float
164
+ ) -> bool:
165
+ if not _circle_rect_collision(center, radius, self.rect):
166
+ return False
167
+ if self._collision_polygon is None:
168
+ return True
169
+ return _circle_polygon_collision(center, radius, self._collision_polygon)
170
+
171
+ def set_palette(
172
+ self: Self, palette: EnvironmentPalette | None, *, force: bool = False
173
+ ) -> None:
174
+ """Update the wall's palette to match the current ambient palette."""
175
+
176
+ if not force and self.palette is palette:
177
+ return
178
+ self.palette = palette
179
+ self._update_color()
180
+
181
+
182
+ class SteelBeam(pygame.sprite.Sprite):
183
+ """Single-cell obstacle that behaves like a tougher internal wall."""
184
+
185
+ def __init__(
186
+ self: Self,
187
+ x: int,
188
+ y: int,
189
+ size: int,
190
+ *,
191
+ health: int = STEEL_BEAM_HEALTH,
192
+ palette: EnvironmentPalette | None = None,
193
+ ) -> None:
194
+ super().__init__()
195
+ # Slightly inset from the cell size so it reads as a separate object.
196
+ margin = max(3, size // 14)
197
+ inset_size = max(4, size - margin * 2)
198
+ self.image = pygame.Surface((inset_size, inset_size), pygame.SRCALPHA)
199
+ self._added_to_groups = False
200
+ self.health = health
201
+ self.max_health = max(1, health)
202
+ self.palette = palette
203
+ self._update_color()
204
+ self.rect = self.image.get_rect(center=(x + size // 2, y + size // 2))
205
+
206
+ def _take_damage(self: Self, *, amount: int = 1) -> None:
207
+ if self.health > 0:
208
+ self.health -= amount
209
+ self._update_color()
210
+ if self.health <= 0:
211
+ self.kill()
212
+
213
+ def _update_color(self: Self) -> None:
214
+ """Render a simple square with crossed diagonals that darkens as damaged."""
215
+ if self.health <= 0:
216
+ return
217
+ health_ratio = max(0, self.health / self.max_health)
218
+ base_color, line_color = resolve_steel_beam_colors(
219
+ health_ratio=health_ratio, palette=self.palette
220
+ )
221
+ paint_steel_beam_surface(
222
+ self.image,
223
+ base_color=base_color,
224
+ line_color=line_color,
225
+ health_ratio=health_ratio,
226
+ )
227
+
228
+
72
229
  MovementStrategy = Callable[
73
230
  [
74
231
  "Zombie",
75
232
  tuple[int, int],
76
- list["Wall"],
77
- list[dict[str, object]],
233
+ list[Wall],
234
+ list[Footprint],
78
235
  int,
79
236
  int,
80
237
  int,
@@ -82,55 +239,17 @@ MovementStrategy = Callable[
82
239
  ],
83
240
  tuple[float, float],
84
241
  ]
85
- WallIndex = dict[tuple[int, int], list["Wall"]]
86
-
87
-
88
- def build_wall_index(walls: Iterable["Wall"], *, cell_size: int) -> WallIndex:
89
- index: WallIndex = {}
90
- for wall in walls:
91
- if not wall.alive():
92
- continue
93
- cell_x = int(wall.rect.centerx // cell_size)
94
- cell_y = int(wall.rect.centery // cell_size)
95
- index.setdefault((cell_x, cell_y), []).append(wall)
96
- return index
97
-
98
-
99
242
  def _sprite_center_and_radius(
100
243
  sprite: pygame.sprite.Sprite,
101
244
  ) -> tuple[tuple[int, int], float]:
102
245
  center = sprite.rect.center
103
- radius = float(
104
- getattr(sprite, "radius", max(sprite.rect.width, sprite.rect.height) / 2)
105
- )
246
+ if hasattr(sprite, "radius"):
247
+ radius = float(sprite.radius)
248
+ else:
249
+ radius = float(max(sprite.rect.width, sprite.rect.height) / 2)
106
250
  return center, radius
107
251
 
108
252
 
109
- def walls_for_radius(
110
- wall_index: WallIndex,
111
- center: tuple[float, float],
112
- radius: float,
113
- *,
114
- cell_size: int,
115
- grid_cols: int | None = None,
116
- grid_rows: int | None = None,
117
- ) -> list["Wall"]:
118
- if grid_cols is None or grid_rows is None:
119
- grid_cols, grid_rows = _infer_grid_size_from_index(wall_index)
120
- if grid_cols is None or grid_rows is None:
121
- return []
122
- search_radius = radius + cell_size
123
- min_x = max(0, int((center[0] - search_radius) // cell_size))
124
- max_x = min(grid_cols - 1, int((center[0] + search_radius) // cell_size))
125
- min_y = max(0, int((center[1] - search_radius) // cell_size))
126
- max_y = min(grid_rows - 1, int((center[1] + search_radius) // cell_size))
127
- candidates: list[Wall] = []
128
- for cy in range(min_y, max_y + 1):
129
- for cx in range(min_x, max_x + 1):
130
- candidates.extend(wall_index.get((cx, cy), []))
131
- return candidates
132
-
133
-
134
253
  def _walls_for_sprite(
135
254
  sprite: pygame.sprite.Sprite,
136
255
  wall_index: WallIndex,
@@ -138,7 +257,7 @@ def _walls_for_sprite(
138
257
  cell_size: int,
139
258
  grid_cols: int | None = None,
140
259
  grid_rows: int | None = None,
141
- ) -> list["Wall"]:
260
+ ) -> list[Wall]:
142
261
  center, radius = _sprite_center_and_radius(sprite)
143
262
  return walls_for_radius(
144
263
  wall_index,
@@ -150,14 +269,6 @@ def _walls_for_sprite(
150
269
  )
151
270
 
152
271
 
153
- def _infer_grid_size_from_index(wall_index: WallIndex) -> tuple[int | None, int | None]:
154
- if not wall_index:
155
- return None, None
156
- max_col = max(cell[0] for cell in wall_index)
157
- max_row = max(cell[1] for cell in wall_index)
158
- return max_col + 1, max_row + 1
159
-
160
-
161
272
  def _circle_rect_collision(
162
273
  center: tuple[float, float], radius: float, rect_obj: rect.Rect
163
274
  ) -> bool:
@@ -253,7 +364,32 @@ def _point_segment_distance_sq(
253
364
  return (px - nearest_x) ** 2 + (py - nearest_y) ** 2
254
365
 
255
366
 
256
- def rect_polygon_collision(
367
+ def _line_of_sight_clear(
368
+ start: tuple[float, float],
369
+ end: tuple[float, float],
370
+ walls: list["Wall"],
371
+ ) -> bool:
372
+ min_x = min(start[0], end[0])
373
+ min_y = min(start[1], end[1])
374
+ max_x = max(start[0], end[0])
375
+ max_y = max(start[1], end[1])
376
+ check_rect = pygame.Rect(
377
+ int(min_x),
378
+ int(min_y),
379
+ max(1, int(max_x - min_x)),
380
+ max(1, int(max_y - min_y)),
381
+ )
382
+ start_point = (int(start[0]), int(start[1]))
383
+ end_point = (int(end[0]), int(end[1]))
384
+ for wall in walls:
385
+ if not wall.rect.colliderect(check_rect):
386
+ continue
387
+ if wall.rect.clipline(start_point, end_point):
388
+ return False
389
+ return True
390
+
391
+
392
+ def _rect_polygon_collision(
257
393
  rect_obj: rect.Rect, polygon: Sequence[tuple[float, float]]
258
394
  ) -> bool:
259
395
  min_x = min(p[0] for p in polygon)
@@ -292,7 +428,7 @@ def rect_polygon_collision(
292
428
  return False
293
429
 
294
430
 
295
- def circle_polygon_collision(
431
+ def _circle_polygon_collision(
296
432
  center: tuple[float, float],
297
433
  radius: float,
298
434
  polygon: Sequence[tuple[float, float]],
@@ -308,12 +444,12 @@ def circle_polygon_collision(
308
444
  return False
309
445
 
310
446
 
311
- def collide_sprite_wall(
447
+ def _collide_sprite_wall(
312
448
  sprite: pygame.sprite.Sprite, wall: pygame.sprite.Sprite
313
449
  ) -> bool:
314
450
  if hasattr(sprite, "radius"):
315
451
  center = sprite.rect.center
316
- radius = float(getattr(sprite, "radius"))
452
+ radius = float(sprite.radius)
317
453
  if hasattr(wall, "_collides_circle"):
318
454
  return wall._collides_circle(center, radius)
319
455
  return _circle_rect_collision(center, radius, wall.rect)
@@ -333,10 +469,13 @@ def _spritecollide_walls(
333
469
  cell_size: int | None = None,
334
470
  grid_cols: int | None = None,
335
471
  grid_rows: int | None = None,
336
- ) -> list[pygame.sprite.Sprite]:
472
+ ) -> list[Wall]:
337
473
  if wall_index is None:
338
- return pygame.sprite.spritecollide(
339
- sprite, walls, dokill, collided=collide_sprite_wall
474
+ return cast(
475
+ list[Wall],
476
+ pygame.sprite.spritecollide(
477
+ sprite, walls, dokill, collided=_collide_sprite_wall
478
+ ),
340
479
  )
341
480
  if cell_size is None:
342
481
  raise ValueError("cell_size is required when using wall_index")
@@ -349,7 +488,7 @@ def _spritecollide_walls(
349
488
  )
350
489
  if not candidates:
351
490
  return []
352
- hit_list = [wall for wall in candidates if collide_sprite_wall(sprite, wall)]
491
+ hit_list = [wall for wall in candidates if _collide_sprite_wall(sprite, wall)]
353
492
  if dokill:
354
493
  for wall in hit_list:
355
494
  wall.kill()
@@ -364,10 +503,13 @@ def spritecollideany_walls(
364
503
  cell_size: int | None = None,
365
504
  grid_cols: int | None = None,
366
505
  grid_rows: int | None = None,
367
- ) -> pygame.sprite.Sprite | None:
506
+ ) -> Wall | None:
368
507
  if wall_index is None:
369
- return pygame.sprite.spritecollideany(
370
- sprite, walls, collided=collide_sprite_wall
508
+ return cast(
509
+ Wall | None,
510
+ pygame.sprite.spritecollideany(
511
+ sprite, walls, collided=_collide_sprite_wall
512
+ ),
371
513
  )
372
514
  if cell_size is None:
373
515
  raise ValueError("cell_size is required when using wall_index")
@@ -378,7 +520,7 @@ def spritecollideany_walls(
378
520
  grid_cols=grid_cols,
379
521
  grid_rows=grid_rows,
380
522
  ):
381
- if collide_sprite_wall(sprite, wall):
523
+ if _collide_sprite_wall(sprite, wall):
382
524
  return wall
383
525
  return None
384
526
 
@@ -393,148 +535,6 @@ def _circle_wall_collision(
393
535
  return _circle_rect_collision(center, radius, wall.rect)
394
536
 
395
537
 
396
- class Wall(pygame.sprite.Sprite):
397
- def __init__(
398
- self: Self,
399
- x: int,
400
- y: int,
401
- width: int,
402
- height: int,
403
- *,
404
- health: int = INTERNAL_WALL_HEALTH,
405
- palette: EnvironmentPalette | None = None,
406
- palette_category: str = "inner_wall",
407
- bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
408
- bevel_mask: tuple[bool, bool, bool, bool] | None = None,
409
- draw_bottom_side: bool = False,
410
- bottom_side_ratio: float = 0.1,
411
- side_shade_ratio: float = 0.9,
412
- on_destroy: Callable[[Self], None] | None = None,
413
- ) -> None:
414
- super().__init__()
415
- safe_width = max(1, width)
416
- safe_height = max(1, height)
417
- self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
418
- self.palette = palette
419
- self.palette_category = palette_category
420
- self.health = health
421
- self.max_health = max(1, health)
422
- self.on_destroy = on_destroy
423
- self.bevel_depth = max(0, bevel_depth)
424
- self.bevel_mask = bevel_mask or (False, False, False, False)
425
- self.draw_bottom_side = draw_bottom_side
426
- self.bottom_side_ratio = max(0.0, bottom_side_ratio)
427
- self.side_shade_ratio = max(0.0, min(1.0, side_shade_ratio))
428
- self._local_polygon = _build_beveled_polygon(
429
- safe_width, safe_height, self.bevel_depth, self.bevel_mask
430
- )
431
- self._update_color()
432
- self.rect = self.image.get_rect(topleft=(x, y))
433
- # Keep collision rectangular even when beveled visually.
434
- self._collision_polygon = None
435
-
436
- def _take_damage(self: Self, *, amount: int = 1) -> None:
437
- if self.health > 0:
438
- self.health -= amount
439
- self._update_color()
440
- if self.health <= 0:
441
- if self.on_destroy:
442
- try:
443
- self.on_destroy(self)
444
- except Exception as exc:
445
- print(f"Wall destroy callback failed: {exc}")
446
- self.kill()
447
-
448
- def _update_color(self: Self) -> None:
449
- if self.health <= 0:
450
- health_ratio = 0.0
451
- else:
452
- health_ratio = max(0.0, self.health / self.max_health)
453
- fill_color, border_color = resolve_wall_colors(
454
- health_ratio=health_ratio,
455
- palette_category=self.palette_category,
456
- palette=self.palette,
457
- )
458
- paint_wall_surface(
459
- self.image,
460
- fill_color=fill_color,
461
- border_color=border_color,
462
- bevel_depth=self.bevel_depth,
463
- bevel_mask=self.bevel_mask,
464
- draw_bottom_side=self.draw_bottom_side,
465
- bottom_side_ratio=self.bottom_side_ratio,
466
- side_shade_ratio=self.side_shade_ratio,
467
- )
468
-
469
- def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
470
- if self._collision_polygon is None:
471
- return self.rect.colliderect(rect_obj)
472
- return rect_polygon_collision(rect_obj, self._collision_polygon)
473
-
474
- def _collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
475
- if not _circle_rect_collision(center, radius, self.rect):
476
- return False
477
- if self._collision_polygon is None:
478
- return True
479
- return circle_polygon_collision(center, radius, self._collision_polygon)
480
-
481
- def set_palette(
482
- self: Self, palette: EnvironmentPalette | None, *, force: bool = False
483
- ) -> None:
484
- """Update the wall's palette to match the current ambient palette."""
485
-
486
- if not force and self.palette is palette:
487
- return
488
- self.palette = palette
489
- self._update_color()
490
-
491
-
492
- class SteelBeam(pygame.sprite.Sprite):
493
- """Single-cell obstacle that behaves like a tougher internal wall."""
494
-
495
- def __init__(
496
- self: Self,
497
- x: int,
498
- y: int,
499
- size: int,
500
- *,
501
- health: int = STEEL_BEAM_HEALTH,
502
- palette: EnvironmentPalette | None = None,
503
- ) -> None:
504
- super().__init__()
505
- # Slightly inset from the cell size so it reads as a separate object.
506
- margin = max(3, size // 14)
507
- inset_size = max(4, size - margin * 2)
508
- self.image = pygame.Surface((inset_size, inset_size), pygame.SRCALPHA)
509
- self.health = health
510
- self.max_health = max(1, health)
511
- self.palette = palette
512
- self._update_color()
513
- self.rect = self.image.get_rect(center=(x + size // 2, y + size // 2))
514
-
515
- def _take_damage(self: Self, *, amount: int = 1) -> None:
516
- if self.health > 0:
517
- self.health -= amount
518
- self._update_color()
519
- if self.health <= 0:
520
- self.kill()
521
-
522
- def _update_color(self: Self) -> None:
523
- """Render a simple square with crossed diagonals that darkens as damaged."""
524
- if self.health <= 0:
525
- return
526
- health_ratio = max(0, self.health / self.max_health)
527
- base_color, line_color = resolve_steel_beam_colors(
528
- health_ratio=health_ratio, palette=self.palette
529
- )
530
- paint_steel_beam_surface(
531
- self.image,
532
- base_color=base_color,
533
- line_color=line_color,
534
- health_ratio=health_ratio,
535
- )
536
-
537
-
538
538
  class Camera:
539
539
  def __init__(self: Self, width: int, height: int) -> None:
540
540
  self.camera = pygame.Rect(0, 0, width, height)
@@ -673,6 +673,11 @@ class Survivor(pygame.sprite.Sprite):
673
673
  *,
674
674
  wall_index: WallIndex | None = None,
675
675
  cell_size: int | None = None,
676
+ wall_cells: set[tuple[int, int]] | None = None,
677
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
678
+ | None = None,
679
+ grid_cols: int | None = None,
680
+ grid_rows: int | None = None,
676
681
  level_width: int | None = None,
677
682
  level_height: int | None = None,
678
683
  ) -> None:
@@ -694,6 +699,24 @@ class Survivor(pygame.sprite.Sprite):
694
699
  move_x = (dx / dist) * BUDDY_FOLLOW_SPEED
695
700
  move_y = (dy / dist) * BUDDY_FOLLOW_SPEED
696
701
 
702
+ if (
703
+ cell_size is not None
704
+ and wall_cells is not None
705
+ and grid_cols is not None
706
+ and grid_rows is not None
707
+ ):
708
+ move_x, move_y = apply_tile_edge_nudge(
709
+ self.x,
710
+ self.y,
711
+ move_x,
712
+ move_y,
713
+ cell_size=cell_size,
714
+ wall_cells=wall_cells,
715
+ bevel_corners=bevel_corners,
716
+ grid_cols=grid_cols,
717
+ grid_rows=grid_rows,
718
+ )
719
+
697
720
  if move_x:
698
721
  self.x += move_x
699
722
  self.rect.centerx = int(self.x)
@@ -746,6 +769,24 @@ class Survivor(pygame.sprite.Sprite):
746
769
  move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
747
770
  move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
748
771
 
772
+ if (
773
+ cell_size is not None
774
+ and wall_cells is not None
775
+ and grid_cols is not None
776
+ and grid_rows is not None
777
+ ):
778
+ move_x, move_y = apply_tile_edge_nudge(
779
+ self.x,
780
+ self.y,
781
+ move_x,
782
+ move_y,
783
+ cell_size=cell_size,
784
+ wall_cells=wall_cells,
785
+ bevel_corners=bevel_corners,
786
+ grid_cols=grid_cols,
787
+ grid_rows=grid_rows,
788
+ )
789
+
749
790
  if move_x:
750
791
  self.x += move_x
751
792
  self.rect.centerx = int(self.x)
@@ -792,7 +833,7 @@ def _zombie_tracker_movement(
792
833
  zombie: Zombie,
793
834
  player_center: tuple[int, int],
794
835
  walls: list[Wall],
795
- footprints: list[dict[str, object]],
836
+ footprints: list[Footprint],
796
837
  cell_size: int,
797
838
  grid_cols: int,
798
839
  grid_rows: int,
@@ -800,9 +841,9 @@ def _zombie_tracker_movement(
800
841
  ) -> tuple[float, float]:
801
842
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
802
843
  if not is_in_sight:
803
- _zombie_update_tracker_target(zombie, footprints)
844
+ _zombie_update_tracker_target(zombie, footprints, walls)
804
845
  if zombie.tracker_target_pos is not None:
805
- return zombie_move_toward(zombie, zombie.tracker_target_pos)
846
+ return _zombie_move_toward(zombie, zombie.tracker_target_pos)
806
847
  return _zombie_wander_move(
807
848
  zombie,
808
849
  walls,
@@ -811,14 +852,14 @@ def _zombie_tracker_movement(
811
852
  grid_rows=grid_rows,
812
853
  outer_wall_cells=outer_wall_cells,
813
854
  )
814
- return zombie_move_toward(zombie, player_center)
855
+ return _zombie_move_toward(zombie, player_center)
815
856
 
816
857
 
817
- def zombie_wander_movement(
858
+ def _zombie_wander_movement(
818
859
  zombie: Zombie,
819
860
  _player_center: tuple[int, int],
820
861
  walls: list[Wall],
821
- _footprints: list[dict[str, object]],
862
+ _footprints: list[Footprint],
822
863
  cell_size: int,
823
864
  grid_cols: int,
824
865
  grid_rows: int,
@@ -834,7 +875,7 @@ def zombie_wander_movement(
834
875
  )
835
876
 
836
877
 
837
- def zombie_wall_follow_has_wall(
878
+ def _zombie_wall_follow_has_wall(
838
879
  zombie: Zombie,
839
880
  walls: list[Wall],
840
881
  angle: float,
@@ -890,7 +931,7 @@ def _zombie_wall_follow_movement(
890
931
  zombie: Zombie,
891
932
  player_center: tuple[int, int],
892
933
  walls: list[Wall],
893
- _footprints: list[dict[str, object]],
934
+ _footprints: list[Footprint],
894
935
  cell_size: int,
895
936
  grid_cols: int,
896
937
  grid_rows: int,
@@ -930,7 +971,7 @@ def _zombie_wall_follow_movement(
930
971
  zombie.wall_follow_last_side_has_wall = left_wall or right_wall
931
972
  else:
932
973
  if is_in_sight:
933
- return zombie_move_toward(zombie, player_center)
974
+ return _zombie_move_toward(zombie, player_center)
934
975
  return _zombie_wander_move(
935
976
  zombie,
936
977
  walls,
@@ -957,7 +998,7 @@ def _zombie_wall_follow_movement(
957
998
  and now - zombie.wall_follow_last_wall_time <= ZOMBIE_WALL_FOLLOW_LOST_WALL_MS
958
999
  )
959
1000
  if is_in_sight:
960
- return zombie_move_toward(zombie, player_center)
1001
+ return _zombie_move_toward(zombie, player_center)
961
1002
 
962
1003
  turn_step = math.radians(5)
963
1004
  if side_has_wall or forward_has_wall:
@@ -990,11 +1031,11 @@ def _zombie_wall_follow_movement(
990
1031
  return move_x, move_y
991
1032
 
992
1033
 
993
- def zombie_normal_movement(
1034
+ def _zombie_normal_movement(
994
1035
  zombie: Zombie,
995
1036
  player_center: tuple[int, int],
996
1037
  walls: list[Wall],
997
- _footprints: list[dict[str, object]],
1038
+ _footprints: list[Footprint],
998
1039
  cell_size: int,
999
1040
  grid_cols: int,
1000
1041
  grid_rows: int,
@@ -1010,38 +1051,72 @@ def zombie_normal_movement(
1010
1051
  grid_rows=grid_rows,
1011
1052
  outer_wall_cells=outer_wall_cells,
1012
1053
  )
1013
- return zombie_move_toward(zombie, player_center)
1054
+ return _zombie_move_toward(zombie, player_center)
1014
1055
 
1015
1056
 
1016
1057
  def _zombie_update_tracker_target(
1017
- zombie: Zombie, footprints: list[dict[str, object]]
1058
+ zombie: Zombie,
1059
+ footprints: list[Footprint],
1060
+ walls: list[Wall],
1018
1061
  ) -> None:
1019
- zombie.tracker_target_pos = None
1062
+ now = pygame.time.get_ticks()
1063
+ if now - zombie.tracker_last_scan_time < zombie.tracker_scan_interval_ms:
1064
+ return
1065
+ zombie.tracker_last_scan_time = now
1020
1066
  if not footprints:
1067
+ zombie.tracker_target_pos = None
1021
1068
  return
1022
- nearby: list[dict[str, object]] = []
1069
+ nearby: list[Footprint] = []
1070
+ last_target_time = zombie.tracker_target_time
1071
+ scan_radius = ZOMBIE_TRACKER_SCENT_RADIUS * ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER
1072
+ scent_radius_sq = scan_radius * scan_radius
1073
+ min_target_dist_sq = (FOOTPRINT_STEP_DISTANCE * 0.5) ** 2
1023
1074
  for fp in footprints:
1024
- pos = fp.get("pos")
1025
- if not isinstance(pos, tuple):
1026
- continue
1075
+ pos = fp.pos
1076
+ fp_time = fp.time
1027
1077
  dx = pos[0] - zombie.x
1028
1078
  dy = pos[1] - zombie.y
1029
- if (
1030
- dx * dx + dy * dy
1031
- <= ZOMBIE_TRACKER_SCENT_RADIUS * ZOMBIE_TRACKER_SCENT_RADIUS
1032
- ):
1079
+ if dx * dx + dy * dy <= min_target_dist_sq:
1080
+ continue
1081
+ if dx * dx + dy * dy <= scent_radius_sq:
1033
1082
  nearby.append(fp)
1034
1083
 
1035
1084
  if not nearby:
1036
1085
  return
1037
1086
 
1038
- latest = max(
1039
- nearby,
1040
- key=lambda fp: fp.get("time", -1) if isinstance(fp.get("time"), int) else -1,
1041
- )
1042
- pos = latest.get("pos")
1043
- if isinstance(pos, tuple):
1044
- zombie.tracker_target_pos = pos
1087
+ nearby.sort(key=lambda fp: fp.time, reverse=True)
1088
+ if last_target_time is not None:
1089
+ newer = [fp for fp in nearby if fp.time > last_target_time]
1090
+ else:
1091
+ newer = nearby
1092
+
1093
+ for fp in newer[:ZOMBIE_TRACKER_SCENT_TOP_K]:
1094
+ pos = fp.pos
1095
+ fp_time = fp.time
1096
+ if _line_of_sight_clear((zombie.x, zombie.y), pos, walls):
1097
+ zombie.tracker_target_pos = pos
1098
+ zombie.tracker_target_time = fp_time
1099
+ return
1100
+
1101
+ if (
1102
+ zombie.tracker_target_pos is not None
1103
+ and (zombie.x - zombie.tracker_target_pos[0]) ** 2
1104
+ + (zombie.y - zombie.tracker_target_pos[1]) ** 2
1105
+ > min_target_dist_sq
1106
+ ):
1107
+ return
1108
+
1109
+ if last_target_time is None:
1110
+ return
1111
+
1112
+ candidates = [fp for fp in nearby if fp.time > last_target_time]
1113
+ if not candidates:
1114
+ return
1115
+ candidates.sort(key=lambda fp: fp.time)
1116
+ next_fp = candidates[0]
1117
+ zombie.tracker_target_pos = next_fp.pos
1118
+ zombie.tracker_target_time = next_fp.time
1119
+ return
1045
1120
 
1046
1121
 
1047
1122
  def _zombie_wander_move(
@@ -1054,33 +1129,45 @@ def _zombie_wander_move(
1054
1129
  outer_wall_cells: set[tuple[int, int]] | None,
1055
1130
  ) -> tuple[float, float]:
1056
1131
  now = pygame.time.get_ticks()
1132
+ changed_angle = False
1057
1133
  if now - zombie.last_wander_change_time > zombie.wander_change_interval:
1058
1134
  zombie.wander_angle = RNG.uniform(0, math.tau)
1059
1135
  zombie.last_wander_change_time = now
1060
1136
  jitter = RNG.randint(-500, 500)
1061
1137
  zombie.wander_change_interval = max(0, zombie.wander_interval_ms + jitter)
1138
+ changed_angle = True
1062
1139
 
1063
1140
  cell_x = int(zombie.x // cell_size)
1064
1141
  cell_y = int(zombie.y // cell_size)
1065
1142
  at_x_edge = cell_x in (0, grid_cols - 1)
1066
1143
  at_y_edge = cell_y in (0, grid_rows - 1)
1144
+ if changed_angle and (at_x_edge or at_y_edge):
1145
+ cos_angle = math.cos(zombie.wander_angle)
1146
+ sin_angle = math.sin(zombie.wander_angle)
1147
+ outward = (
1148
+ (cell_x == 0 and cos_angle < 0)
1149
+ or (cell_x == grid_cols - 1 and cos_angle > 0)
1150
+ or (cell_y == 0 and sin_angle < 0)
1151
+ or (cell_y == grid_rows - 1 and sin_angle > 0)
1152
+ )
1153
+ if outward:
1154
+ if RNG.random() < 0.5:
1155
+ zombie.wander_angle = (zombie.wander_angle + math.pi) % math.tau
1067
1156
 
1068
1157
  if at_x_edge or at_y_edge:
1069
1158
  if outer_wall_cells is not None:
1070
1159
  if at_x_edge:
1071
- inward_cell = (
1072
- (1, cell_y) if cell_x == 0 else (grid_cols - 2, cell_y)
1073
- )
1160
+ inward_cell = (1, cell_y) if cell_x == 0 else (grid_cols - 2, cell_y)
1074
1161
  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
1162
+ target_x = (inward_cell[0] + 0.5) * cell_size
1163
+ target_y = (inward_cell[1] + 0.5) * cell_size
1164
+ return _zombie_move_toward(zombie, (target_x, target_y))
1077
1165
  if at_y_edge:
1078
- inward_cell = (
1079
- (cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
1080
- )
1166
+ inward_cell = (cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
1081
1167
  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
1168
+ target_x = (inward_cell[0] + 0.5) * cell_size
1169
+ target_y = (inward_cell[1] + 0.5) * cell_size
1170
+ return _zombie_move_toward(zombie, (target_x, target_y))
1084
1171
  else:
1085
1172
 
1086
1173
  def path_clear(next_x: float, next_y: float) -> bool:
@@ -1103,19 +1190,13 @@ def _zombie_wander_move(
1103
1190
  inward_dy = zombie.speed if cell_y == 0 else -zombie.speed
1104
1191
  if path_clear(zombie.x, zombie.y + inward_dy):
1105
1192
  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
1112
1193
 
1113
1194
  move_x = math.cos(zombie.wander_angle) * zombie.speed
1114
1195
  move_y = math.sin(zombie.wander_angle) * zombie.speed
1115
1196
  return move_x, move_y
1116
1197
 
1117
1198
 
1118
- def zombie_move_toward(
1199
+ def _zombie_move_toward(
1119
1200
  zombie: Zombie, target: tuple[float, float]
1120
1201
  ) -> tuple[float, float]:
1121
1202
  dx = target[0] - zombie.x
@@ -1164,9 +1245,12 @@ class Zombie(pygame.sprite.Sprite):
1164
1245
  elif wall_follower:
1165
1246
  movement_strategy = _zombie_wall_follow_movement
1166
1247
  else:
1167
- movement_strategy = zombie_normal_movement
1248
+ movement_strategy = _zombie_normal_movement
1168
1249
  self.movement_strategy = movement_strategy
1169
1250
  self.tracker_target_pos: tuple[float, float] | None = None
1251
+ self.tracker_target_time: int | None = None
1252
+ self.tracker_last_scan_time = 0
1253
+ self.tracker_scan_interval_ms = ZOMBIE_TRACKER_SCAN_INTERVAL_MS
1170
1254
  self.wall_follow_side = RNG.choice([-1.0, 1.0]) if wall_follower else 0.0
1171
1255
  self.wall_follow_angle = RNG.uniform(0, math.tau) if wall_follower else None
1172
1256
  self.wall_follow_last_wall_time: int | None = None
@@ -1230,47 +1314,8 @@ class Zombie(pygame.sprite.Sprite):
1230
1314
  final_y = self.y
1231
1315
  break
1232
1316
 
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
1317
  return final_x, final_y
1239
1318
 
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
1319
  def _avoid_other_zombies(
1275
1320
  self: Self,
1276
1321
  move_x: float,
@@ -1302,7 +1347,7 @@ class Zombie(pygame.sprite.Sprite):
1302
1347
  return move_x, move_y
1303
1348
 
1304
1349
  if self.wall_follower:
1305
- other_radius = float(getattr(closest, "radius", self.radius))
1350
+ other_radius = float(closest.radius)
1306
1351
  bump_dist_sq = (self.radius + other_radius) ** 2
1307
1352
  if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
1308
1353
  if self.wall_follow_angle is None:
@@ -1360,7 +1405,7 @@ class Zombie(pygame.sprite.Sprite):
1360
1405
  player_center: tuple[int, int],
1361
1406
  walls: list[Wall],
1362
1407
  nearby_zombies: Iterable[Zombie],
1363
- footprints: list[dict[str, object]] | None = None,
1408
+ footprints: list[Footprint] | None = None,
1364
1409
  *,
1365
1410
  cell_size: int,
1366
1411
  grid_cols: int,
@@ -1368,6 +1413,9 @@ class Zombie(pygame.sprite.Sprite):
1368
1413
  level_width: int,
1369
1414
  level_height: int,
1370
1415
  outer_wall_cells: set[tuple[int, int]] | None = None,
1416
+ wall_cells: set[tuple[int, int]] | None = None,
1417
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]]
1418
+ | None = None,
1371
1419
  ) -> None:
1372
1420
  if self.carbonized:
1373
1421
  return
@@ -1390,6 +1438,18 @@ class Zombie(pygame.sprite.Sprite):
1390
1438
  )
1391
1439
  if dist_to_player_sq <= avoid_radius_sq or self.wall_follower:
1392
1440
  move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
1441
+ if wall_cells is not None:
1442
+ move_x, move_y = apply_tile_edge_nudge(
1443
+ self.x,
1444
+ self.y,
1445
+ move_x,
1446
+ move_y,
1447
+ cell_size=cell_size,
1448
+ wall_cells=wall_cells,
1449
+ bevel_corners=bevel_corners,
1450
+ grid_cols=grid_cols,
1451
+ grid_rows=grid_rows,
1452
+ )
1393
1453
  if self.wall_follower and self.wall_follow_side != 0:
1394
1454
  if move_x != 0 or move_y != 0:
1395
1455
  heading = math.atan2(move_y, move_x)
@@ -1407,9 +1467,8 @@ class Zombie(pygame.sprite.Sprite):
1407
1467
  )
1408
1468
 
1409
1469
  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
- )
1470
+ self.kill()
1471
+ return
1413
1472
 
1414
1473
  self.x = final_x
1415
1474
  self.y = final_y
@@ -1462,7 +1521,14 @@ class Car(pygame.sprite.Sprite):
1462
1521
  old_center = self.rect.center
1463
1522
  self.rect = self.image.get_rect(center=old_center)
1464
1523
 
1465
- def move(self: Self, dx: float, dy: float, walls: Iterable[Wall]) -> None:
1524
+ def move(
1525
+ self: Self,
1526
+ dx: float,
1527
+ dy: float,
1528
+ walls: Iterable[Wall],
1529
+ *,
1530
+ walls_nearby: bool = False,
1531
+ ) -> None:
1466
1532
  if self.health <= 0:
1467
1533
  return
1468
1534
  if dx == 0 and dy == 0:
@@ -1477,11 +1543,15 @@ class Car(pygame.sprite.Sprite):
1477
1543
  new_y = self.y + dy
1478
1544
 
1479
1545
  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
- ]
1546
+ if walls_nearby:
1547
+ possible_walls = list(walls)
1548
+ else:
1549
+ possible_walls = [
1550
+ w
1551
+ for w in walls
1552
+ if abs(w.rect.centery - self.y) < 100
1553
+ and abs(w.rect.centerx - new_x) < 100
1554
+ ]
1485
1555
  car_center = (new_x, new_y)
1486
1556
  for wall in possible_walls:
1487
1557
  if _circle_wall_collision(car_center, self.collision_radius, wall):