zombie-escape 1.8.0__py3-none-any.whl → 1.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,135 +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
- def apply_tile_edge_nudge(
135
- x: float,
136
- y: float,
137
- dx: float,
138
- dy: float,
139
- *,
140
- cell_size: int,
141
- wall_cells: set[tuple[int, int]] | None,
142
- bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] | None = None,
143
- grid_cols: int,
144
- grid_rows: int,
145
- strength: float = 0.03,
146
- edge_margin_ratio: float = 0.15,
147
- min_margin: float = 2.0,
148
- ) -> tuple[float, float]:
149
- if dx == 0 and dy == 0:
150
- return dx, dy
151
- if cell_size <= 0 or not wall_cells:
152
- return dx, dy
153
- cell_x = int(x // cell_size)
154
- cell_y = int(y // cell_size)
155
- if cell_x < 0 or cell_y < 0 or cell_x >= grid_cols or cell_y >= grid_rows:
156
- return dx, dy
157
- speed = math.hypot(dx, dy)
158
- if speed <= 0:
159
- return dx, dy
160
-
161
- edge_margin = max(min_margin, cell_size * edge_margin_ratio)
162
- left_dist = x - (cell_x * cell_size)
163
- right_dist = ((cell_x + 1) * cell_size) - x
164
- top_dist = y - (cell_y * cell_size)
165
- bottom_dist = ((cell_y + 1) * cell_size) - y
166
-
167
- def apply_push(dist: float, direction: float) -> float:
168
- if dist >= edge_margin:
169
- return 0.0
170
- ratio = (edge_margin - dist) / edge_margin
171
- return ratio * speed * strength * direction
172
-
173
- if (cell_x - 1, cell_y) in wall_cells:
174
- dx += apply_push(left_dist, 1.0)
175
- if (cell_x + 1, cell_y) in wall_cells:
176
- dx += apply_push(right_dist, -1.0)
177
- if (cell_x, cell_y - 1) in wall_cells:
178
- dy += apply_push(top_dist, 1.0)
179
- if (cell_x, cell_y + 1) in wall_cells:
180
- dy += apply_push(bottom_dist, -1.0)
181
-
182
- def apply_corner_push(dist_a: float, dist_b: float, boost: float = 1.0) -> float:
183
- if dist_a >= edge_margin or dist_b >= edge_margin:
184
- return 0.0
185
- ratio = (edge_margin - min(dist_a, dist_b)) / edge_margin
186
- return ratio * speed * strength * boost
187
-
188
- if bevel_corners:
189
- boosted = 1.25
190
- corner_wall = bevel_corners.get((cell_x - 1, cell_y - 1))
191
- if corner_wall and corner_wall[2]:
192
- push = apply_corner_push(left_dist, top_dist, boosted)
193
- dx += push
194
- dy += push
195
- corner_wall = bevel_corners.get((cell_x + 1, cell_y - 1))
196
- if corner_wall and corner_wall[3]:
197
- push = apply_corner_push(right_dist, top_dist, boosted)
198
- dx -= push
199
- dy += push
200
- corner_wall = bevel_corners.get((cell_x + 1, cell_y + 1))
201
- if corner_wall and corner_wall[0]:
202
- push = apply_corner_push(right_dist, bottom_dist, boosted)
203
- dx -= push
204
- dy -= push
205
- corner_wall = bevel_corners.get((cell_x - 1, cell_y + 1))
206
- if corner_wall and corner_wall[1]:
207
- push = apply_corner_push(left_dist, bottom_dist, boosted)
208
- dx += push
209
- dy -= push
210
-
211
- return dx, dy
212
-
213
-
214
253
  def _walls_for_sprite(
215
254
  sprite: pygame.sprite.Sprite,
216
255
  wall_index: WallIndex,
@@ -218,7 +257,7 @@ def _walls_for_sprite(
218
257
  cell_size: int,
219
258
  grid_cols: int | None = None,
220
259
  grid_rows: int | None = None,
221
- ) -> list["Wall"]:
260
+ ) -> list[Wall]:
222
261
  center, radius = _sprite_center_and_radius(sprite)
223
262
  return walls_for_radius(
224
263
  wall_index,
@@ -230,14 +269,6 @@ def _walls_for_sprite(
230
269
  )
231
270
 
232
271
 
233
- def _infer_grid_size_from_index(wall_index: WallIndex) -> tuple[int | None, int | None]:
234
- if not wall_index:
235
- return None, None
236
- max_col = max(cell[0] for cell in wall_index)
237
- max_row = max(cell[1] for cell in wall_index)
238
- return max_col + 1, max_row + 1
239
-
240
-
241
272
  def _circle_rect_collision(
242
273
  center: tuple[float, float], radius: float, rect_obj: rect.Rect
243
274
  ) -> bool:
@@ -333,7 +364,32 @@ def _point_segment_distance_sq(
333
364
  return (px - nearest_x) ** 2 + (py - nearest_y) ** 2
334
365
 
335
366
 
336
- 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(
337
393
  rect_obj: rect.Rect, polygon: Sequence[tuple[float, float]]
338
394
  ) -> bool:
339
395
  min_x = min(p[0] for p in polygon)
@@ -372,7 +428,7 @@ def rect_polygon_collision(
372
428
  return False
373
429
 
374
430
 
375
- def circle_polygon_collision(
431
+ def _circle_polygon_collision(
376
432
  center: tuple[float, float],
377
433
  radius: float,
378
434
  polygon: Sequence[tuple[float, float]],
@@ -388,12 +444,12 @@ def circle_polygon_collision(
388
444
  return False
389
445
 
390
446
 
391
- def collide_sprite_wall(
447
+ def _collide_sprite_wall(
392
448
  sprite: pygame.sprite.Sprite, wall: pygame.sprite.Sprite
393
449
  ) -> bool:
394
450
  if hasattr(sprite, "radius"):
395
451
  center = sprite.rect.center
396
- radius = float(getattr(sprite, "radius"))
452
+ radius = float(sprite.radius)
397
453
  if hasattr(wall, "_collides_circle"):
398
454
  return wall._collides_circle(center, radius)
399
455
  return _circle_rect_collision(center, radius, wall.rect)
@@ -413,10 +469,13 @@ def _spritecollide_walls(
413
469
  cell_size: int | None = None,
414
470
  grid_cols: int | None = None,
415
471
  grid_rows: int | None = None,
416
- ) -> list[pygame.sprite.Sprite]:
472
+ ) -> list[Wall]:
417
473
  if wall_index is None:
418
- return pygame.sprite.spritecollide(
419
- 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
+ ),
420
479
  )
421
480
  if cell_size is None:
422
481
  raise ValueError("cell_size is required when using wall_index")
@@ -429,7 +488,7 @@ def _spritecollide_walls(
429
488
  )
430
489
  if not candidates:
431
490
  return []
432
- 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)]
433
492
  if dokill:
434
493
  for wall in hit_list:
435
494
  wall.kill()
@@ -444,10 +503,13 @@ def spritecollideany_walls(
444
503
  cell_size: int | None = None,
445
504
  grid_cols: int | None = None,
446
505
  grid_rows: int | None = None,
447
- ) -> pygame.sprite.Sprite | None:
506
+ ) -> Wall | None:
448
507
  if wall_index is None:
449
- return pygame.sprite.spritecollideany(
450
- sprite, walls, collided=collide_sprite_wall
508
+ return cast(
509
+ Wall | None,
510
+ pygame.sprite.spritecollideany(
511
+ sprite, walls, collided=_collide_sprite_wall
512
+ ),
451
513
  )
452
514
  if cell_size is None:
453
515
  raise ValueError("cell_size is required when using wall_index")
@@ -458,7 +520,7 @@ def spritecollideany_walls(
458
520
  grid_cols=grid_cols,
459
521
  grid_rows=grid_rows,
460
522
  ):
461
- if collide_sprite_wall(sprite, wall):
523
+ if _collide_sprite_wall(sprite, wall):
462
524
  return wall
463
525
  return None
464
526
 
@@ -473,150 +535,6 @@ def _circle_wall_collision(
473
535
  return _circle_rect_collision(center, radius, wall.rect)
474
536
 
475
537
 
476
- class Wall(pygame.sprite.Sprite):
477
- def __init__(
478
- self: Self,
479
- x: int,
480
- y: int,
481
- width: int,
482
- height: int,
483
- *,
484
- health: int = INTERNAL_WALL_HEALTH,
485
- palette: EnvironmentPalette | None = None,
486
- palette_category: str = "inner_wall",
487
- bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
488
- bevel_mask: tuple[bool, bool, bool, bool] | None = None,
489
- draw_bottom_side: bool = False,
490
- bottom_side_ratio: float = 0.1,
491
- side_shade_ratio: float = 0.9,
492
- on_destroy: Callable[[Self], None] | None = None,
493
- ) -> None:
494
- super().__init__()
495
- safe_width = max(1, width)
496
- safe_height = max(1, height)
497
- self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
498
- self.palette = palette
499
- self.palette_category = palette_category
500
- self.health = health
501
- self.max_health = max(1, health)
502
- self.on_destroy = on_destroy
503
- self.bevel_depth = max(0, bevel_depth)
504
- self.bevel_mask = bevel_mask or (False, False, False, False)
505
- self.draw_bottom_side = draw_bottom_side
506
- self.bottom_side_ratio = max(0.0, bottom_side_ratio)
507
- self.side_shade_ratio = max(0.0, min(1.0, side_shade_ratio))
508
- self._local_polygon = _build_beveled_polygon(
509
- safe_width, safe_height, self.bevel_depth, self.bevel_mask
510
- )
511
- self._update_color()
512
- self.rect = self.image.get_rect(topleft=(x, y))
513
- # Keep collision rectangular even when beveled visually.
514
- self._collision_polygon = None
515
-
516
- def _take_damage(self: Self, *, amount: int = 1) -> None:
517
- if self.health > 0:
518
- self.health -= amount
519
- self._update_color()
520
- if self.health <= 0:
521
- if self.on_destroy:
522
- try:
523
- self.on_destroy(self)
524
- except Exception as exc:
525
- print(f"Wall destroy callback failed: {exc}")
526
- self.kill()
527
-
528
- def _update_color(self: Self) -> None:
529
- if self.health <= 0:
530
- health_ratio = 0.0
531
- else:
532
- health_ratio = max(0.0, self.health / self.max_health)
533
- fill_color, border_color = resolve_wall_colors(
534
- health_ratio=health_ratio,
535
- palette_category=self.palette_category,
536
- palette=self.palette,
537
- )
538
- paint_wall_surface(
539
- self.image,
540
- fill_color=fill_color,
541
- border_color=border_color,
542
- bevel_depth=self.bevel_depth,
543
- bevel_mask=self.bevel_mask,
544
- draw_bottom_side=self.draw_bottom_side,
545
- bottom_side_ratio=self.bottom_side_ratio,
546
- side_shade_ratio=self.side_shade_ratio,
547
- )
548
-
549
- def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
550
- if self._collision_polygon is None:
551
- return self.rect.colliderect(rect_obj)
552
- return rect_polygon_collision(rect_obj, self._collision_polygon)
553
-
554
- def _collides_circle(
555
- self: Self, center: tuple[float, float], radius: float
556
- ) -> bool:
557
- if not _circle_rect_collision(center, radius, self.rect):
558
- return False
559
- if self._collision_polygon is None:
560
- return True
561
- return circle_polygon_collision(center, radius, self._collision_polygon)
562
-
563
- def set_palette(
564
- self: Self, palette: EnvironmentPalette | None, *, force: bool = False
565
- ) -> None:
566
- """Update the wall's palette to match the current ambient palette."""
567
-
568
- if not force and self.palette is palette:
569
- return
570
- self.palette = palette
571
- self._update_color()
572
-
573
-
574
- class SteelBeam(pygame.sprite.Sprite):
575
- """Single-cell obstacle that behaves like a tougher internal wall."""
576
-
577
- def __init__(
578
- self: Self,
579
- x: int,
580
- y: int,
581
- size: int,
582
- *,
583
- health: int = STEEL_BEAM_HEALTH,
584
- palette: EnvironmentPalette | None = None,
585
- ) -> None:
586
- super().__init__()
587
- # Slightly inset from the cell size so it reads as a separate object.
588
- margin = max(3, size // 14)
589
- inset_size = max(4, size - margin * 2)
590
- self.image = pygame.Surface((inset_size, inset_size), pygame.SRCALPHA)
591
- self.health = health
592
- self.max_health = max(1, health)
593
- self.palette = palette
594
- self._update_color()
595
- self.rect = self.image.get_rect(center=(x + size // 2, y + size // 2))
596
-
597
- def _take_damage(self: Self, *, amount: int = 1) -> None:
598
- if self.health > 0:
599
- self.health -= amount
600
- self._update_color()
601
- if self.health <= 0:
602
- self.kill()
603
-
604
- def _update_color(self: Self) -> None:
605
- """Render a simple square with crossed diagonals that darkens as damaged."""
606
- if self.health <= 0:
607
- return
608
- health_ratio = max(0, self.health / self.max_health)
609
- base_color, line_color = resolve_steel_beam_colors(
610
- health_ratio=health_ratio, palette=self.palette
611
- )
612
- paint_steel_beam_surface(
613
- self.image,
614
- base_color=base_color,
615
- line_color=line_color,
616
- health_ratio=health_ratio,
617
- )
618
-
619
-
620
538
  class Camera:
621
539
  def __init__(self: Self, width: int, height: int) -> None:
622
540
  self.camera = pygame.Rect(0, 0, width, height)
@@ -915,7 +833,7 @@ def _zombie_tracker_movement(
915
833
  zombie: Zombie,
916
834
  player_center: tuple[int, int],
917
835
  walls: list[Wall],
918
- footprints: list[dict[str, object]],
836
+ footprints: list[Footprint],
919
837
  cell_size: int,
920
838
  grid_cols: int,
921
839
  grid_rows: int,
@@ -923,9 +841,9 @@ def _zombie_tracker_movement(
923
841
  ) -> tuple[float, float]:
924
842
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
925
843
  if not is_in_sight:
926
- _zombie_update_tracker_target(zombie, footprints)
844
+ _zombie_update_tracker_target(zombie, footprints, walls)
927
845
  if zombie.tracker_target_pos is not None:
928
- return zombie_move_toward(zombie, zombie.tracker_target_pos)
846
+ return _zombie_move_toward(zombie, zombie.tracker_target_pos)
929
847
  return _zombie_wander_move(
930
848
  zombie,
931
849
  walls,
@@ -934,14 +852,14 @@ def _zombie_tracker_movement(
934
852
  grid_rows=grid_rows,
935
853
  outer_wall_cells=outer_wall_cells,
936
854
  )
937
- return zombie_move_toward(zombie, player_center)
855
+ return _zombie_move_toward(zombie, player_center)
938
856
 
939
857
 
940
- def zombie_wander_movement(
858
+ def _zombie_wander_movement(
941
859
  zombie: Zombie,
942
860
  _player_center: tuple[int, int],
943
861
  walls: list[Wall],
944
- _footprints: list[dict[str, object]],
862
+ _footprints: list[Footprint],
945
863
  cell_size: int,
946
864
  grid_cols: int,
947
865
  grid_rows: int,
@@ -957,7 +875,7 @@ def zombie_wander_movement(
957
875
  )
958
876
 
959
877
 
960
- def zombie_wall_follow_has_wall(
878
+ def _zombie_wall_follow_has_wall(
961
879
  zombie: Zombie,
962
880
  walls: list[Wall],
963
881
  angle: float,
@@ -1013,7 +931,7 @@ def _zombie_wall_follow_movement(
1013
931
  zombie: Zombie,
1014
932
  player_center: tuple[int, int],
1015
933
  walls: list[Wall],
1016
- _footprints: list[dict[str, object]],
934
+ _footprints: list[Footprint],
1017
935
  cell_size: int,
1018
936
  grid_cols: int,
1019
937
  grid_rows: int,
@@ -1053,7 +971,7 @@ def _zombie_wall_follow_movement(
1053
971
  zombie.wall_follow_last_side_has_wall = left_wall or right_wall
1054
972
  else:
1055
973
  if is_in_sight:
1056
- return zombie_move_toward(zombie, player_center)
974
+ return _zombie_move_toward(zombie, player_center)
1057
975
  return _zombie_wander_move(
1058
976
  zombie,
1059
977
  walls,
@@ -1080,7 +998,7 @@ def _zombie_wall_follow_movement(
1080
998
  and now - zombie.wall_follow_last_wall_time <= ZOMBIE_WALL_FOLLOW_LOST_WALL_MS
1081
999
  )
1082
1000
  if is_in_sight:
1083
- return zombie_move_toward(zombie, player_center)
1001
+ return _zombie_move_toward(zombie, player_center)
1084
1002
 
1085
1003
  turn_step = math.radians(5)
1086
1004
  if side_has_wall or forward_has_wall:
@@ -1113,11 +1031,11 @@ def _zombie_wall_follow_movement(
1113
1031
  return move_x, move_y
1114
1032
 
1115
1033
 
1116
- def zombie_normal_movement(
1034
+ def _zombie_normal_movement(
1117
1035
  zombie: Zombie,
1118
1036
  player_center: tuple[int, int],
1119
1037
  walls: list[Wall],
1120
- _footprints: list[dict[str, object]],
1038
+ _footprints: list[Footprint],
1121
1039
  cell_size: int,
1122
1040
  grid_cols: int,
1123
1041
  grid_rows: int,
@@ -1133,38 +1051,72 @@ def zombie_normal_movement(
1133
1051
  grid_rows=grid_rows,
1134
1052
  outer_wall_cells=outer_wall_cells,
1135
1053
  )
1136
- return zombie_move_toward(zombie, player_center)
1054
+ return _zombie_move_toward(zombie, player_center)
1137
1055
 
1138
1056
 
1139
1057
  def _zombie_update_tracker_target(
1140
- zombie: Zombie, footprints: list[dict[str, object]]
1058
+ zombie: Zombie,
1059
+ footprints: list[Footprint],
1060
+ walls: list[Wall],
1141
1061
  ) -> None:
1142
- 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
1143
1066
  if not footprints:
1067
+ zombie.tracker_target_pos = None
1144
1068
  return
1145
- 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
1146
1074
  for fp in footprints:
1147
- pos = fp.get("pos")
1148
- if not isinstance(pos, tuple):
1149
- continue
1075
+ pos = fp.pos
1076
+ fp_time = fp.time
1150
1077
  dx = pos[0] - zombie.x
1151
1078
  dy = pos[1] - zombie.y
1152
- if (
1153
- dx * dx + dy * dy
1154
- <= ZOMBIE_TRACKER_SCENT_RADIUS * ZOMBIE_TRACKER_SCENT_RADIUS
1155
- ):
1079
+ if dx * dx + dy * dy <= min_target_dist_sq:
1080
+ continue
1081
+ if dx * dx + dy * dy <= scent_radius_sq:
1156
1082
  nearby.append(fp)
1157
1083
 
1158
1084
  if not nearby:
1159
1085
  return
1160
1086
 
1161
- latest = max(
1162
- nearby,
1163
- key=lambda fp: fp.get("time", -1) if isinstance(fp.get("time"), int) else -1,
1164
- )
1165
- pos = latest.get("pos")
1166
- if isinstance(pos, tuple):
1167
- 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
1168
1120
 
1169
1121
 
1170
1122
  def _zombie_wander_move(
@@ -1177,16 +1129,30 @@ def _zombie_wander_move(
1177
1129
  outer_wall_cells: set[tuple[int, int]] | None,
1178
1130
  ) -> tuple[float, float]:
1179
1131
  now = pygame.time.get_ticks()
1132
+ changed_angle = False
1180
1133
  if now - zombie.last_wander_change_time > zombie.wander_change_interval:
1181
1134
  zombie.wander_angle = RNG.uniform(0, math.tau)
1182
1135
  zombie.last_wander_change_time = now
1183
1136
  jitter = RNG.randint(-500, 500)
1184
1137
  zombie.wander_change_interval = max(0, zombie.wander_interval_ms + jitter)
1138
+ changed_angle = True
1185
1139
 
1186
1140
  cell_x = int(zombie.x // cell_size)
1187
1141
  cell_y = int(zombie.y // cell_size)
1188
1142
  at_x_edge = cell_x in (0, grid_cols - 1)
1189
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
1190
1156
 
1191
1157
  if at_x_edge or at_y_edge:
1192
1158
  if outer_wall_cells is not None:
@@ -1195,13 +1161,13 @@ def _zombie_wander_move(
1195
1161
  if inward_cell not in outer_wall_cells:
1196
1162
  target_x = (inward_cell[0] + 0.5) * cell_size
1197
1163
  target_y = (inward_cell[1] + 0.5) * cell_size
1198
- return zombie_move_toward(zombie, (target_x, target_y))
1164
+ return _zombie_move_toward(zombie, (target_x, target_y))
1199
1165
  if at_y_edge:
1200
1166
  inward_cell = (cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
1201
1167
  if inward_cell not in outer_wall_cells:
1202
1168
  target_x = (inward_cell[0] + 0.5) * cell_size
1203
1169
  target_y = (inward_cell[1] + 0.5) * cell_size
1204
- return zombie_move_toward(zombie, (target_x, target_y))
1170
+ return _zombie_move_toward(zombie, (target_x, target_y))
1205
1171
  else:
1206
1172
 
1207
1173
  def path_clear(next_x: float, next_y: float) -> bool:
@@ -1224,19 +1190,13 @@ def _zombie_wander_move(
1224
1190
  inward_dy = zombie.speed if cell_y == 0 else -zombie.speed
1225
1191
  if path_clear(zombie.x, zombie.y + inward_dy):
1226
1192
  return 0.0, inward_dy
1227
- # if at_x_edge:
1228
- # direction = 1.0 if math.sin(zombie.wander_angle) >= 0 else -1.0
1229
- # return 0.0, direction * zombie.speed
1230
- # if at_y_edge:
1231
- # direction = 1.0 if math.cos(zombie.wander_angle) >= 0 else -1.0
1232
- # return direction * zombie.speed, 0.0
1233
1193
 
1234
1194
  move_x = math.cos(zombie.wander_angle) * zombie.speed
1235
1195
  move_y = math.sin(zombie.wander_angle) * zombie.speed
1236
1196
  return move_x, move_y
1237
1197
 
1238
1198
 
1239
- def zombie_move_toward(
1199
+ def _zombie_move_toward(
1240
1200
  zombie: Zombie, target: tuple[float, float]
1241
1201
  ) -> tuple[float, float]:
1242
1202
  dx = target[0] - zombie.x
@@ -1285,9 +1245,12 @@ class Zombie(pygame.sprite.Sprite):
1285
1245
  elif wall_follower:
1286
1246
  movement_strategy = _zombie_wall_follow_movement
1287
1247
  else:
1288
- movement_strategy = zombie_normal_movement
1248
+ movement_strategy = _zombie_normal_movement
1289
1249
  self.movement_strategy = movement_strategy
1290
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
1291
1254
  self.wall_follow_side = RNG.choice([-1.0, 1.0]) if wall_follower else 0.0
1292
1255
  self.wall_follow_angle = RNG.uniform(0, math.tau) if wall_follower else None
1293
1256
  self.wall_follow_last_wall_time: int | None = None
@@ -1384,7 +1347,7 @@ class Zombie(pygame.sprite.Sprite):
1384
1347
  return move_x, move_y
1385
1348
 
1386
1349
  if self.wall_follower:
1387
- other_radius = float(getattr(closest, "radius", self.radius))
1350
+ other_radius = float(closest.radius)
1388
1351
  bump_dist_sq = (self.radius + other_radius) ** 2
1389
1352
  if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
1390
1353
  if self.wall_follow_angle is None:
@@ -1442,7 +1405,7 @@ class Zombie(pygame.sprite.Sprite):
1442
1405
  player_center: tuple[int, int],
1443
1406
  walls: list[Wall],
1444
1407
  nearby_zombies: Iterable[Zombie],
1445
- footprints: list[dict[str, object]] | None = None,
1408
+ footprints: list[Footprint] | None = None,
1446
1409
  *,
1447
1410
  cell_size: int,
1448
1411
  grid_cols: int,