zombie-escape 1.5.4__py3-none-any.whl → 1.7.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.
Files changed (37) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/entities.py +501 -537
  3. zombie_escape/entities_constants.py +102 -0
  4. zombie_escape/gameplay/__init__.py +75 -2
  5. zombie_escape/gameplay/ambient.py +50 -0
  6. zombie_escape/gameplay/constants.py +46 -0
  7. zombie_escape/gameplay/footprints.py +60 -0
  8. zombie_escape/gameplay/interactions.py +354 -0
  9. zombie_escape/gameplay/layout.py +190 -0
  10. zombie_escape/gameplay/movement.py +220 -0
  11. zombie_escape/gameplay/spawn.py +618 -0
  12. zombie_escape/gameplay/state.py +137 -0
  13. zombie_escape/gameplay/survivors.py +306 -0
  14. zombie_escape/gameplay/utils.py +147 -0
  15. zombie_escape/gameplay_constants.py +0 -148
  16. zombie_escape/level_blueprints.py +123 -10
  17. zombie_escape/level_constants.py +6 -13
  18. zombie_escape/locales/ui.en.json +10 -1
  19. zombie_escape/locales/ui.ja.json +10 -1
  20. zombie_escape/models.py +15 -9
  21. zombie_escape/render.py +42 -27
  22. zombie_escape/render_assets.py +533 -23
  23. zombie_escape/render_constants.py +57 -22
  24. zombie_escape/rng.py +9 -9
  25. zombie_escape/screens/__init__.py +59 -29
  26. zombie_escape/screens/game_over.py +3 -3
  27. zombie_escape/screens/gameplay.py +45 -27
  28. zombie_escape/screens/title.py +5 -2
  29. zombie_escape/stage_constants.py +34 -1
  30. zombie_escape/zombie_escape.py +30 -12
  31. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/METADATA +1 -1
  32. zombie_escape-1.7.1.dist-info/RECORD +45 -0
  33. zombie_escape/gameplay/logic.py +0 -1917
  34. zombie_escape-1.5.4.dist-info/RECORD +0 -35
  35. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/WHEEL +0 -0
  36. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/entry_points.txt +0 -0
  37. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/entities.py CHANGED
@@ -8,36 +8,19 @@ from typing import Callable, Iterable, Self, Sequence
8
8
  import pygame
9
9
  from pygame import rect
10
10
 
11
- from .colors import (
12
- BLACK,
13
- BLUE,
14
- DARK_RED,
15
- INTERNAL_WALL_BORDER_COLOR,
16
- INTERNAL_WALL_COLOR,
17
- ORANGE,
18
- RED,
19
- STEEL_BEAM_COLOR,
20
- STEEL_BEAM_LINE_COLOR,
21
- TRACKER_OUTLINE_COLOR,
22
- WALL_FOLLOWER_OUTLINE_COLOR,
23
- YELLOW,
24
- )
25
- from .gameplay_constants import (
11
+ from .entities_constants import (
12
+ BUDDY_FOLLOW_SPEED,
13
+ BUDDY_RADIUS,
26
14
  CAR_HEALTH,
27
15
  CAR_HEIGHT,
28
16
  CAR_SPEED,
29
17
  CAR_WALL_DAMAGE,
30
18
  CAR_WIDTH,
31
- BUDDY_COLOR,
32
- BUDDY_FOLLOW_SPEED,
33
- BUDDY_RADIUS,
34
19
  FAST_ZOMBIE_BASE_SPEED,
35
20
  FLASHLIGHT_HEIGHT,
36
21
  FLASHLIGHT_WIDTH,
37
22
  FUEL_CAN_HEIGHT,
38
23
  FUEL_CAN_WIDTH,
39
- HUMANOID_OUTLINE_COLOR,
40
- HUMANOID_OUTLINE_WIDTH,
41
24
  INTERNAL_WALL_BEVEL_DEPTH,
42
25
  INTERNAL_WALL_HEALTH,
43
26
  PLAYER_RADIUS,
@@ -46,7 +29,6 @@ from .gameplay_constants import (
46
29
  STEEL_BEAM_HEALTH,
47
30
  SURVIVOR_APPROACH_RADIUS,
48
31
  SURVIVOR_APPROACH_SPEED,
49
- SURVIVOR_COLOR,
50
32
  SURVIVOR_RADIUS,
51
33
  ZOMBIE_AGING_DURATION_FRAMES,
52
34
  ZOMBIE_AGING_MIN_SPEED_RATIO,
@@ -64,28 +46,52 @@ from .gameplay_constants import (
64
46
  ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
65
47
  ZOMBIE_WALL_FOLLOW_TARGET_GAP,
66
48
  ZOMBIE_WANDER_INTERVAL_MS,
67
- car_body_radius,
68
49
  )
69
- from .level_constants import CELL_SIZE, GRID_COLS, GRID_ROWS, LEVEL_HEIGHT, LEVEL_WIDTH
70
- from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
50
+ from .render_assets import (
51
+ EnvironmentPalette,
52
+ build_beveled_polygon,
53
+ build_car_surface,
54
+ build_flashlight_surface,
55
+ build_fuel_can_surface,
56
+ build_player_surface,
57
+ build_survivor_surface,
58
+ build_zombie_surface,
59
+ paint_car_surface,
60
+ paint_steel_beam_surface,
61
+ paint_wall_surface,
62
+ paint_zombie_surface,
63
+ resolve_car_color,
64
+ resolve_steel_beam_colors,
65
+ resolve_wall_colors,
66
+ )
71
67
  from .rng import get_rng
68
+ from .screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
72
69
 
73
70
  RNG = get_rng()
74
71
 
75
72
  MovementStrategy = Callable[
76
- ["Zombie", tuple[int, int], list["Wall"], list[dict[str, object]]],
73
+ [
74
+ "Zombie",
75
+ tuple[int, int],
76
+ list["Wall"],
77
+ list[dict[str, object]],
78
+ int,
79
+ int,
80
+ int,
81
+ set[tuple[int, int]] | None,
82
+ ],
77
83
  tuple[float, float],
78
84
  ]
79
85
  WallIndex = dict[tuple[int, int], list["Wall"]]
80
86
 
81
87
 
82
- def build_wall_index(walls: Iterable["Wall"]) -> WallIndex:
88
+ def build_wall_index(walls: Iterable["Wall"], *, cell_size: int) -> WallIndex:
83
89
  index: WallIndex = {}
84
90
  for wall in walls:
85
91
  if not wall.alive():
86
92
  continue
87
- cell_x = int(wall.rect.centerx // CELL_SIZE)
88
- cell_y = int(wall.rect.centery // CELL_SIZE)
93
+ cell_x = int(wall.rect.centerx // cell_size)
94
+ cell_y = int(wall.rect.centery // cell_size)
89
95
  index.setdefault((cell_x, cell_y), []).append(wall)
90
96
  return index
91
97
 
@@ -101,13 +107,23 @@ def _sprite_center_and_radius(
101
107
 
102
108
 
103
109
  def walls_for_radius(
104
- wall_index: WallIndex, center: tuple[float, float], radius: float
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,
105
117
  ) -> list["Wall"]:
106
- search_radius = radius + CELL_SIZE
107
- min_x = max(0, int((center[0] - search_radius) // CELL_SIZE))
108
- max_x = min(GRID_COLS - 1, int((center[0] + search_radius) // CELL_SIZE))
109
- min_y = max(0, int((center[1] - search_radius) // CELL_SIZE))
110
- max_y = min(GRID_ROWS - 1, int((center[1] + search_radius) // CELL_SIZE))
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))
111
127
  candidates: list[Wall] = []
112
128
  for cy in range(min_y, max_y + 1):
113
129
  for cx in range(min_x, max_x + 1):
@@ -116,13 +132,33 @@ def walls_for_radius(
116
132
 
117
133
 
118
134
  def _walls_for_sprite(
119
- sprite: pygame.sprite.Sprite, wall_index: WallIndex
135
+ sprite: pygame.sprite.Sprite,
136
+ wall_index: WallIndex,
137
+ *,
138
+ cell_size: int,
139
+ grid_cols: int | None = None,
140
+ grid_rows: int | None = None,
120
141
  ) -> list["Wall"]:
121
142
  center, radius = _sprite_center_and_radius(sprite)
122
- return walls_for_radius(wall_index, center, radius)
143
+ return walls_for_radius(
144
+ wall_index,
145
+ center,
146
+ radius,
147
+ cell_size=cell_size,
148
+ grid_cols=grid_cols,
149
+ grid_rows=grid_rows,
150
+ )
123
151
 
124
152
 
125
- def circle_rect_collision(
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
+ def _circle_rect_collision(
126
162
  center: tuple[float, float], radius: float, rect_obj: rect.Rect
127
163
  ) -> bool:
128
164
  """Return True if a circle overlaps the provided rectangle."""
@@ -134,80 +170,13 @@ def circle_rect_collision(
134
170
  return dx * dx + dy * dy <= radius * radius
135
171
 
136
172
 
137
- def _draw_outlined_circle(
138
- surface: pygame.Surface,
139
- center: tuple[int, int],
140
- radius: int,
141
- fill_color: tuple[int, int, int],
142
- outline_color: tuple[int, int, int],
143
- outline_width: int,
144
- ) -> None:
145
- pygame.draw.circle(surface, fill_color, center, radius)
146
- if outline_width > 0:
147
- pygame.draw.circle(surface, outline_color, center, radius, width=outline_width)
148
-
149
-
150
173
  def _build_beveled_polygon(
151
174
  width: int,
152
175
  height: int,
153
176
  depth: int,
154
177
  bevels: tuple[bool, bool, bool, bool],
155
178
  ) -> list[tuple[int, int]]:
156
- d = max(0, min(depth, width // 2, height // 2))
157
- if d == 0 or not any(bevels):
158
- return [(0, 0), (width, 0), (width, height), (0, height)]
159
-
160
- segments = 4
161
- tl, tr, br, bl = bevels
162
- points: list[tuple[int, int]] = []
163
-
164
- def add_point(x: float, y: float) -> None:
165
- point = (int(round(x)), int(round(y)))
166
- if not points or points[-1] != point:
167
- points.append(point)
168
-
169
- def add_arc(
170
- center_x: float,
171
- center_y: float,
172
- radius: float,
173
- start_deg: float,
174
- end_deg: float,
175
- *,
176
- skip_first: bool = False,
177
- skip_last: bool = False,
178
- ) -> None:
179
- for i in range(segments + 1):
180
- if skip_first and i == 0:
181
- continue
182
- if skip_last and i == segments:
183
- continue
184
- t = i / segments
185
- angle = math.radians(start_deg + (end_deg - start_deg) * t)
186
- add_point(
187
- center_x + radius * math.cos(angle),
188
- center_y + radius * math.sin(angle),
189
- )
190
-
191
- add_point(d if tl else 0, 0)
192
- if tr:
193
- add_point(width - d, 0)
194
- add_arc(width - d, d, d, -90, 0, skip_first=True)
195
- else:
196
- add_point(width, 0)
197
- if br:
198
- add_point(width, height - d)
199
- add_arc(width - d, height - d, d, 0, 90, skip_first=True)
200
- else:
201
- add_point(width, height)
202
- if bl:
203
- add_point(d, height)
204
- add_arc(d, height - d, d, 90, 180, skip_first=True)
205
- else:
206
- add_point(0, height)
207
- if tl:
208
- add_point(0, d)
209
- add_arc(d, d, d, 180, 270, skip_first=True, skip_last=True)
210
- return points
179
+ return build_beveled_polygon(width, height, depth, bevels)
211
180
 
212
181
 
213
182
  def _point_in_polygon(
@@ -345,9 +314,9 @@ def collide_sprite_wall(
345
314
  if hasattr(sprite, "radius"):
346
315
  center = sprite.rect.center
347
316
  radius = float(getattr(sprite, "radius"))
348
- if hasattr(wall, "collides_circle"):
349
- return wall.collides_circle(center, radius)
350
- return circle_rect_collision(center, radius, wall.rect)
317
+ if hasattr(wall, "_collides_circle"):
318
+ return wall._collides_circle(center, radius)
319
+ return _circle_rect_collision(center, radius, wall.rect)
351
320
  if hasattr(wall, "collides_rect"):
352
321
  return wall.collides_rect(sprite.rect)
353
322
  if hasattr(sprite, "collides_rect"):
@@ -355,18 +324,29 @@ def collide_sprite_wall(
355
324
  return sprite.rect.colliderect(wall.rect)
356
325
 
357
326
 
358
- def spritecollide_walls(
327
+ def _spritecollide_walls(
359
328
  sprite: pygame.sprite.Sprite,
360
329
  walls: pygame.sprite.Group,
361
330
  *,
362
331
  dokill: bool = False,
363
332
  wall_index: WallIndex | None = None,
333
+ cell_size: int | None = None,
334
+ grid_cols: int | None = None,
335
+ grid_rows: int | None = None,
364
336
  ) -> list[pygame.sprite.Sprite]:
365
337
  if wall_index is None:
366
338
  return pygame.sprite.spritecollide(
367
339
  sprite, walls, dokill, collided=collide_sprite_wall
368
340
  )
369
- candidates = _walls_for_sprite(sprite, wall_index)
341
+ if cell_size is None:
342
+ raise ValueError("cell_size is required when using wall_index")
343
+ candidates = _walls_for_sprite(
344
+ sprite,
345
+ wall_index,
346
+ cell_size=cell_size,
347
+ grid_cols=grid_cols,
348
+ grid_rows=grid_rows,
349
+ )
370
350
  if not candidates:
371
351
  return []
372
352
  hit_list = [wall for wall in candidates if collide_sprite_wall(sprite, wall)]
@@ -381,28 +361,38 @@ def spritecollideany_walls(
381
361
  walls: pygame.sprite.Group,
382
362
  *,
383
363
  wall_index: WallIndex | None = None,
364
+ cell_size: int | None = None,
365
+ grid_cols: int | None = None,
366
+ grid_rows: int | None = None,
384
367
  ) -> pygame.sprite.Sprite | None:
385
368
  if wall_index is None:
386
369
  return pygame.sprite.spritecollideany(
387
370
  sprite, walls, collided=collide_sprite_wall
388
371
  )
389
- for wall in _walls_for_sprite(sprite, wall_index):
372
+ if cell_size is None:
373
+ raise ValueError("cell_size is required when using wall_index")
374
+ for wall in _walls_for_sprite(
375
+ sprite,
376
+ wall_index,
377
+ cell_size=cell_size,
378
+ grid_cols=grid_cols,
379
+ grid_rows=grid_rows,
380
+ ):
390
381
  if collide_sprite_wall(sprite, wall):
391
382
  return wall
392
383
  return None
393
384
 
394
385
 
395
- def circle_wall_collision(
386
+ def _circle_wall_collision(
396
387
  center: tuple[float, float],
397
388
  radius: float,
398
389
  wall: pygame.sprite.Sprite,
399
390
  ) -> bool:
400
- if hasattr(wall, "collides_circle"):
401
- return wall.collides_circle(center, radius)
402
- return circle_rect_collision(center, radius, wall.rect)
391
+ if hasattr(wall, "_collides_circle"):
392
+ return wall._collides_circle(center, radius)
393
+ return _circle_rect_collision(center, radius, wall.rect)
403
394
 
404
395
 
405
- # --- Camera Class ---
406
396
  class Wall(pygame.sprite.Sprite):
407
397
  def __init__(
408
398
  self: Self,
@@ -412,8 +402,7 @@ class Wall(pygame.sprite.Sprite):
412
402
  height: int,
413
403
  *,
414
404
  health: int = INTERNAL_WALL_HEALTH,
415
- color: tuple[int, int, int] = INTERNAL_WALL_COLOR,
416
- border_color: tuple[int, int, int] = INTERNAL_WALL_BORDER_COLOR,
405
+ palette: EnvironmentPalette | None = None,
417
406
  palette_category: str = "inner_wall",
418
407
  bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
419
408
  bevel_mask: tuple[bool, bool, bool, bool] | None = None,
@@ -426,8 +415,7 @@ class Wall(pygame.sprite.Sprite):
426
415
  safe_width = max(1, width)
427
416
  safe_height = max(1, height)
428
417
  self.image = pygame.Surface((safe_width, safe_height), pygame.SRCALPHA)
429
- self.base_color = color
430
- self.border_base_color = border_color
418
+ self.palette = palette
431
419
  self.palette_category = palette_category
432
420
  self.health = health
433
421
  self.max_health = max(1, health)
@@ -440,18 +428,15 @@ class Wall(pygame.sprite.Sprite):
440
428
  self._local_polygon = _build_beveled_polygon(
441
429
  safe_width, safe_height, self.bevel_depth, self.bevel_mask
442
430
  )
443
- self.update_color()
431
+ self._update_color()
444
432
  self.rect = self.image.get_rect(topleft=(x, y))
445
- self._collision_polygon = (
446
- [(px + self.rect.x, py + self.rect.y) for px, py in self._local_polygon]
447
- if self.bevel_depth > 0 and any(self.bevel_mask)
448
- else None
449
- )
433
+ # Keep collision rectangular even when beveled visually.
434
+ self._collision_polygon = None
450
435
 
451
- def take_damage(self: Self, *, amount: int = 1) -> None:
436
+ def _take_damage(self: Self, *, amount: int = 1) -> None:
452
437
  if self.health > 0:
453
438
  self.health -= amount
454
- self.update_color()
439
+ self._update_color()
455
440
  if self.health <= 0:
456
441
  if self.on_destroy:
457
442
  try:
@@ -460,135 +445,61 @@ class Wall(pygame.sprite.Sprite):
460
445
  print(f"Wall destroy callback failed: {exc}")
461
446
  self.kill()
462
447
 
463
- def update_color(self: Self) -> None:
464
- self.image.fill((0, 0, 0, 0))
448
+ def _update_color(self: Self) -> None:
465
449
  if self.health <= 0:
466
- health_ratio = 0
467
- fill_color = (40, 40, 40)
468
- else:
469
- health_ratio = max(0, self.health / self.max_health)
470
- mix = (
471
- 0.6 + 0.4 * health_ratio
472
- ) # keep at least 60% of the base color even when nearly destroyed
473
- r = int(self.base_color[0] * mix)
474
- g = int(self.base_color[1] * mix)
475
- b = int(self.base_color[2] * mix)
476
- fill_color = (r, g, b)
477
- # Bright edge to separate walls from floor
478
- br = int(self.border_base_color[0] * (0.6 + 0.4 * health_ratio))
479
- bg = int(self.border_base_color[1] * (0.6 + 0.4 * health_ratio))
480
- bb = int(self.border_base_color[2] * (0.6 + 0.4 * health_ratio))
481
- border_color = (br, bg, bb)
482
-
483
- rect_obj = self.image.get_rect()
484
- side_height = 0
485
- if self.draw_bottom_side:
486
- side_height = max(1, int(rect_obj.height * self.bottom_side_ratio))
487
-
488
- def draw_face(
489
- target: pygame.Surface,
490
- *,
491
- face_size: tuple[int, int] | None = None,
492
- ) -> None:
493
- face_width, face_height = face_size or target.get_size()
494
- if self.bevel_depth > 0 and any(self.bevel_mask):
495
- face_polygon = _build_beveled_polygon(
496
- face_width, face_height, self.bevel_depth, self.bevel_mask
497
- )
498
- pygame.draw.polygon(target, border_color, face_polygon)
499
- else:
500
- target.fill(border_color)
501
- border_width = 18
502
- inner_rect = target.get_rect().inflate(-border_width, -border_width)
503
- if inner_rect.width > 0 and inner_rect.height > 0:
504
- inner_depth = max(0, self.bevel_depth - border_width)
505
- if inner_depth > 0 and any(self.bevel_mask):
506
- inner_polygon = _build_beveled_polygon(
507
- inner_rect.width,
508
- inner_rect.height,
509
- inner_depth,
510
- self.bevel_mask,
511
- )
512
- inner_points = [
513
- (px + inner_rect.x, py + inner_rect.y)
514
- for px, py in inner_polygon
515
- ]
516
- pygame.draw.polygon(target, fill_color, inner_points)
517
- else:
518
- pygame.draw.rect(target, fill_color, inner_rect)
519
-
520
- if self.draw_bottom_side:
521
- extra_height = max(0, int(self.bevel_depth / 2))
522
- side_draw_height = min(rect_obj.height, side_height + extra_height)
523
- side_rect = pygame.Rect(
524
- rect_obj.left,
525
- rect_obj.bottom - side_draw_height,
526
- rect_obj.width,
527
- side_draw_height,
528
- )
529
- side_color = tuple(int(c * self.side_shade_ratio) for c in fill_color)
530
- side_surface = pygame.Surface(rect_obj.size, pygame.SRCALPHA)
531
- if self.bevel_depth > 0 and any(self.bevel_mask):
532
- pygame.draw.polygon(side_surface, side_color, self._local_polygon)
533
- else:
534
- pygame.draw.rect(side_surface, side_color, rect_obj)
535
- self.image.blit(side_surface, side_rect.topleft, area=side_rect)
536
-
537
- if self.draw_bottom_side:
538
- top_height = max(0, rect_obj.height - side_height)
539
- top_rect = pygame.Rect(
540
- rect_obj.left,
541
- rect_obj.top,
542
- rect_obj.width,
543
- rect_obj.height - side_height,
544
- )
545
- top_surface = pygame.Surface((rect_obj.width, top_height), pygame.SRCALPHA)
546
- draw_face(
547
- top_surface,
548
- face_size=(rect_obj.width, top_height),
549
- )
550
- if top_rect.height > 0:
551
- self.image.blit(top_surface, top_rect.topleft, area=top_rect)
450
+ health_ratio = 0.0
552
451
  else:
553
- draw_face(self.image)
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
+ )
554
468
 
555
469
  def collides_rect(self: Self, rect_obj: rect.Rect) -> bool:
556
470
  if self._collision_polygon is None:
557
471
  return self.rect.colliderect(rect_obj)
558
472
  return rect_polygon_collision(rect_obj, self._collision_polygon)
559
473
 
560
- def collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
561
- if not circle_rect_collision(center, radius, self.rect):
474
+ def _collides_circle(self: Self, center: tuple[float, float], radius: float) -> bool:
475
+ if not _circle_rect_collision(center, radius, self.rect):
562
476
  return False
563
477
  if self._collision_polygon is None:
564
478
  return True
565
479
  return circle_polygon_collision(center, radius, self._collision_polygon)
566
480
 
567
- def set_palette_colors(
568
- self: Self,
569
- *,
570
- color: tuple[int, int, int],
571
- border_color: tuple[int, int, int],
572
- force: bool = False,
481
+ def set_palette(
482
+ self: Self, palette: EnvironmentPalette | None, *, force: bool = False
573
483
  ) -> None:
574
- """Update the wall's base colors to match the current ambient palette."""
484
+ """Update the wall's palette to match the current ambient palette."""
575
485
 
576
- if (
577
- not force
578
- and self.base_color == color
579
- and self.border_base_color == border_color
580
- ):
486
+ if not force and self.palette is palette:
581
487
  return
582
- self.base_color = color
583
- self.border_base_color = border_color
584
- self.update_color()
488
+ self.palette = palette
489
+ self._update_color()
585
490
 
586
491
 
587
492
  class SteelBeam(pygame.sprite.Sprite):
588
493
  """Single-cell obstacle that behaves like a tougher internal wall."""
589
494
 
590
495
  def __init__(
591
- self: Self, x: int, y: int, size: int, *, health: int = STEEL_BEAM_HEALTH
496
+ self: Self,
497
+ x: int,
498
+ y: int,
499
+ size: int,
500
+ *,
501
+ health: int = STEEL_BEAM_HEALTH,
502
+ palette: EnvironmentPalette | None = None,
592
503
  ) -> None:
593
504
  super().__init__()
594
505
  # Slightly inset from the cell size so it reads as a separate object.
@@ -597,56 +508,31 @@ class SteelBeam(pygame.sprite.Sprite):
597
508
  self.image = pygame.Surface((inset_size, inset_size), pygame.SRCALPHA)
598
509
  self.health = health
599
510
  self.max_health = max(1, health)
600
- self.base_color = STEEL_BEAM_COLOR
601
- self.line_color = STEEL_BEAM_LINE_COLOR
602
- self.update_color()
511
+ self.palette = palette
512
+ self._update_color()
603
513
  self.rect = self.image.get_rect(center=(x + size // 2, y + size // 2))
604
514
 
605
- def take_damage(self: Self, *, amount: int = 1) -> None:
515
+ def _take_damage(self: Self, *, amount: int = 1) -> None:
606
516
  if self.health > 0:
607
517
  self.health -= amount
608
- self.update_color()
518
+ self._update_color()
609
519
  if self.health <= 0:
610
520
  self.kill()
611
521
 
612
- def update_color(self: Self) -> None:
522
+ def _update_color(self: Self) -> None:
613
523
  """Render a simple square with crossed diagonals that darkens as damaged."""
614
- self.image.fill((0, 0, 0, 0))
615
524
  if self.health <= 0:
616
525
  return
617
526
  health_ratio = max(0, self.health / self.max_health)
618
- fill_mix = 0.55 + 0.45 * health_ratio
619
- fill_color = tuple(int(c * fill_mix) for c in self.base_color)
620
- rect_obj = self.image.get_rect()
621
- side_height = max(1, int(rect_obj.height * 0.1))
622
- top_rect = pygame.Rect(
623
- rect_obj.left,
624
- rect_obj.top,
625
- rect_obj.width,
626
- rect_obj.height - side_height,
627
- )
628
- side_mix = 0.45 + 0.35 * health_ratio
629
- side_color = tuple(int(c * side_mix * 0.9) for c in self.base_color)
630
- side_rect = pygame.Rect(
631
- rect_obj.left,
632
- rect_obj.bottom - side_height,
633
- rect_obj.width,
634
- side_height,
527
+ base_color, line_color = resolve_steel_beam_colors(
528
+ health_ratio=health_ratio, palette=self.palette
635
529
  )
636
- pygame.draw.rect(self.image, side_color, side_rect)
637
- line_mix = 0.7 + 0.3 * health_ratio
638
- line_color = tuple(int(c * line_mix) for c in self.line_color)
639
- top_surface = pygame.Surface(top_rect.size, pygame.SRCALPHA)
640
- local_rect = top_surface.get_rect()
641
- pygame.draw.rect(top_surface, fill_color, local_rect)
642
- pygame.draw.rect(top_surface, line_color, local_rect, width=6)
643
- pygame.draw.line(
644
- top_surface, line_color, local_rect.topleft, local_rect.bottomright, width=6
645
- )
646
- pygame.draw.line(
647
- top_surface, line_color, local_rect.topright, local_rect.bottomleft, width=6
530
+ paint_steel_beam_surface(
531
+ self.image,
532
+ base_color=base_color,
533
+ line_color=line_color,
534
+ health_ratio=health_ratio,
648
535
  )
649
- self.image.blit(top_surface, top_rect.topleft)
650
536
 
651
537
 
652
538
  class Camera:
@@ -671,20 +557,14 @@ class Camera:
671
557
 
672
558
  # --- Game Classes ---
673
559
  class Player(pygame.sprite.Sprite):
674
- def __init__(self: Self, x: float, y: float) -> None:
560
+ def __init__(
561
+ self: Self,
562
+ x: float,
563
+ y: float,
564
+ ) -> None:
675
565
  super().__init__()
676
566
  self.radius = PLAYER_RADIUS
677
- self.image = pygame.Surface(
678
- (self.radius * 2 + 2, self.radius * 2 + 2), pygame.SRCALPHA
679
- )
680
- _draw_outlined_circle(
681
- self.image,
682
- (self.radius + 1, self.radius + 1),
683
- self.radius,
684
- BLUE,
685
- HUMANOID_OUTLINE_COLOR,
686
- HUMANOID_OUTLINE_WIDTH,
687
- )
567
+ self.image = build_player_surface(self.radius)
688
568
  self.rect = self.image.get_rect(center=(x, y))
689
569
  self.speed = PLAYER_SPEED
690
570
  self.in_car = False
@@ -698,33 +578,49 @@ class Player(pygame.sprite.Sprite):
698
578
  walls: pygame.sprite.Group,
699
579
  *,
700
580
  wall_index: WallIndex | None = None,
581
+ cell_size: int | None = None,
582
+ level_width: int | None = None,
583
+ level_height: int | None = None,
701
584
  ) -> None:
702
585
  if self.in_car:
703
586
  return
704
587
 
588
+ if level_width is None or level_height is None:
589
+ raise ValueError("level_width/level_height are required for movement")
590
+
705
591
  if dx != 0:
706
592
  self.x += dx
707
- self.x = min(LEVEL_WIDTH, max(0, self.x))
593
+ self.x = min(level_width, max(0, self.x))
708
594
  self.rect.centerx = int(self.x)
709
- hit_list_x = spritecollide_walls(self, walls, wall_index=wall_index)
595
+ hit_list_x = _spritecollide_walls(
596
+ self,
597
+ walls,
598
+ wall_index=wall_index,
599
+ cell_size=cell_size,
600
+ )
710
601
  if hit_list_x:
711
602
  damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_x))
712
603
  for wall in hit_list_x:
713
604
  if wall.alive():
714
- wall.take_damage(amount=damage)
605
+ wall._take_damage(amount=damage)
715
606
  self.x -= dx * 1.5
716
607
  self.rect.centerx = int(self.x)
717
608
 
718
609
  if dy != 0:
719
610
  self.y += dy
720
- self.y = min(LEVEL_HEIGHT, max(0, self.y))
611
+ self.y = min(level_height, max(0, self.y))
721
612
  self.rect.centery = int(self.y)
722
- hit_list_y = spritecollide_walls(self, walls, wall_index=wall_index)
613
+ hit_list_y = _spritecollide_walls(
614
+ self,
615
+ walls,
616
+ wall_index=wall_index,
617
+ cell_size=cell_size,
618
+ )
723
619
  if hit_list_y:
724
620
  damage = max(1, PLAYER_WALL_DAMAGE // len(hit_list_y))
725
621
  for wall in hit_list_y:
726
622
  if wall.alive():
727
- wall.take_damage(amount=damage)
623
+ wall._take_damage(amount=damage)
728
624
  self.y -= dy * 1.5
729
625
  self.rect.centery = int(self.y)
730
626
 
@@ -734,19 +630,19 @@ class Player(pygame.sprite.Sprite):
734
630
  class Survivor(pygame.sprite.Sprite):
735
631
  """Civilians that gather near the player; optional buddy behavior."""
736
632
 
737
- def __init__(self: Self, x: float, y: float, *, is_buddy: bool = False) -> None:
633
+ def __init__(
634
+ self: Self,
635
+ x: float,
636
+ y: float,
637
+ *,
638
+ is_buddy: bool = False,
639
+ ) -> None:
738
640
  super().__init__()
739
641
  self.is_buddy = is_buddy
740
642
  self.radius = BUDDY_RADIUS if is_buddy else SURVIVOR_RADIUS
741
- self.image = pygame.Surface((self.radius * 2, self.radius * 2), pygame.SRCALPHA)
742
- fill_color = BUDDY_COLOR if is_buddy else SURVIVOR_COLOR
743
- _draw_outlined_circle(
744
- self.image,
745
- (self.radius, self.radius),
643
+ self.image = build_survivor_surface(
746
644
  self.radius,
747
- fill_color,
748
- HUMANOID_OUTLINE_COLOR,
749
- HUMANOID_OUTLINE_WIDTH,
645
+ is_buddy=is_buddy,
750
646
  )
751
647
  self.rect = self.image.get_rect(center=(int(x), int(y)))
752
648
  self.x = float(self.rect.centerx)
@@ -776,7 +672,12 @@ class Survivor(pygame.sprite.Sprite):
776
672
  walls: pygame.sprite.Group,
777
673
  *,
778
674
  wall_index: WallIndex | None = None,
675
+ cell_size: int | None = None,
676
+ level_width: int | None = None,
677
+ level_height: int | None = None,
779
678
  ) -> None:
679
+ if level_width is None or level_height is None:
680
+ raise ValueError("level_width/level_height are required for movement")
780
681
  if self.is_buddy:
781
682
  if self.rescued or not self.following:
782
683
  self.rect.center = (int(self.x), int(self.y))
@@ -784,96 +685,155 @@ class Survivor(pygame.sprite.Sprite):
784
685
 
785
686
  dx = player_pos[0] - self.x
786
687
  dy = player_pos[1] - self.y
787
- dist = math.hypot(dx, dy)
788
- if dist <= 0:
688
+ dist_sq = dx * dx + dy * dy
689
+ if dist_sq <= 0:
789
690
  self.rect.center = (int(self.x), int(self.y))
790
691
  return
791
692
 
693
+ dist = math.sqrt(dist_sq)
792
694
  move_x = (dx / dist) * BUDDY_FOLLOW_SPEED
793
695
  move_y = (dy / dist) * BUDDY_FOLLOW_SPEED
794
696
 
795
697
  if move_x:
796
698
  self.x += move_x
797
699
  self.rect.centerx = int(self.x)
798
- if spritecollideany_walls(self, walls, wall_index=wall_index):
700
+ if spritecollideany_walls(
701
+ self,
702
+ walls,
703
+ wall_index=wall_index,
704
+ cell_size=cell_size,
705
+ ):
799
706
  self.x -= move_x
800
707
  self.rect.centerx = int(self.x)
801
708
  if move_y:
802
709
  self.y += move_y
803
710
  self.rect.centery = int(self.y)
804
- if spritecollideany_walls(self, walls, wall_index=wall_index):
711
+ if spritecollideany_walls(
712
+ self,
713
+ walls,
714
+ wall_index=wall_index,
715
+ cell_size=cell_size,
716
+ ):
805
717
  self.y -= move_y
806
718
  self.rect.centery = int(self.y)
807
719
 
808
720
  overlap_radius = (self.radius + PLAYER_RADIUS) * 1.05
809
721
  dx_after = player_pos[0] - self.x
810
722
  dy_after = player_pos[1] - self.y
811
- dist_after = math.hypot(dx_after, dy_after)
812
- if dist_after > 0 and dist_after < overlap_radius:
723
+ dist_after_sq = dx_after * dx_after + dy_after * dy_after
724
+ if 0 < dist_after_sq < overlap_radius * overlap_radius:
725
+ dist_after = math.sqrt(dist_after_sq)
813
726
  push_dist = overlap_radius - dist_after
814
727
  self.x -= (dx_after / dist_after) * push_dist
815
728
  self.y -= (dy_after / dist_after) * push_dist
816
729
  self.rect.center = (int(self.x), int(self.y))
817
730
 
818
- self.x = min(LEVEL_WIDTH, max(0, self.x))
819
- self.y = min(LEVEL_HEIGHT, max(0, self.y))
731
+ self.x = min(level_width, max(0, self.x))
732
+ self.y = min(level_height, max(0, self.y))
820
733
  self.rect.center = (int(self.x), int(self.y))
821
734
  return
822
735
 
823
736
  dx = player_pos[0] - self.x
824
737
  dy = player_pos[1] - self.y
825
- dist = math.hypot(dx, dy)
826
- if dist <= 0 or dist > SURVIVOR_APPROACH_RADIUS:
738
+ dist_sq = dx * dx + dy * dy
739
+ if (
740
+ dist_sq <= 0
741
+ or dist_sq > SURVIVOR_APPROACH_RADIUS * SURVIVOR_APPROACH_RADIUS
742
+ ):
827
743
  return
828
744
 
745
+ dist = math.sqrt(dist_sq)
829
746
  move_x = (dx / dist) * SURVIVOR_APPROACH_SPEED
830
747
  move_y = (dy / dist) * SURVIVOR_APPROACH_SPEED
831
748
 
832
749
  if move_x:
833
750
  self.x += move_x
834
751
  self.rect.centerx = int(self.x)
835
- if spritecollideany_walls(self, walls, wall_index=wall_index):
752
+ if spritecollideany_walls(
753
+ self,
754
+ walls,
755
+ wall_index=wall_index,
756
+ cell_size=cell_size,
757
+ ):
836
758
  self.x -= move_x
837
759
  self.rect.centerx = int(self.x)
838
760
  if move_y:
839
761
  self.y += move_y
840
762
  self.rect.centery = int(self.y)
841
- if spritecollideany_walls(self, walls, wall_index=wall_index):
763
+ if spritecollideany_walls(
764
+ self,
765
+ walls,
766
+ wall_index=wall_index,
767
+ cell_size=cell_size,
768
+ ):
842
769
  self.y -= move_y
843
770
  self.rect.centery = int(self.y)
844
771
 
845
772
  self.rect.center = (int(self.x), int(self.y))
846
773
 
847
774
 
848
- def random_position_outside_building() -> tuple[int, int]:
775
+ def random_position_outside_building(
776
+ level_width: int, level_height: int
777
+ ) -> tuple[int, int]:
849
778
  side = RNG.choice(["top", "bottom", "left", "right"])
850
779
  margin = 0
851
780
  if side == "top":
852
- x, y = RNG.randint(0, LEVEL_WIDTH), -margin
781
+ x, y = RNG.randint(0, level_width), -margin
853
782
  elif side == "bottom":
854
- x, y = RNG.randint(0, LEVEL_WIDTH), LEVEL_HEIGHT + margin
783
+ x, y = RNG.randint(0, level_width), level_height + margin
855
784
  elif side == "left":
856
- x, y = -margin, RNG.randint(0, LEVEL_HEIGHT)
785
+ x, y = -margin, RNG.randint(0, level_height)
857
786
  else:
858
- x, y = LEVEL_WIDTH + margin, RNG.randint(0, LEVEL_HEIGHT)
787
+ x, y = level_width + margin, RNG.randint(0, level_height)
859
788
  return x, y
860
789
 
861
790
 
862
- def zombie_tracker_movement(
791
+ def _zombie_tracker_movement(
863
792
  zombie: Zombie,
864
793
  player_center: tuple[int, int],
865
794
  walls: list[Wall],
866
795
  footprints: list[dict[str, object]],
796
+ cell_size: int,
797
+ grid_cols: int,
798
+ grid_rows: int,
799
+ outer_wall_cells: set[tuple[int, int]] | None,
867
800
  ) -> tuple[float, float]:
868
801
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
869
802
  if not is_in_sight:
870
- zombie_update_tracker_target(zombie, footprints)
803
+ _zombie_update_tracker_target(zombie, footprints)
871
804
  if zombie.tracker_target_pos is not None:
872
805
  return zombie_move_toward(zombie, zombie.tracker_target_pos)
873
- return zombie_wander_move(zombie, walls)
806
+ return _zombie_wander_move(
807
+ zombie,
808
+ walls,
809
+ cell_size=cell_size,
810
+ grid_cols=grid_cols,
811
+ grid_rows=grid_rows,
812
+ outer_wall_cells=outer_wall_cells,
813
+ )
874
814
  return zombie_move_toward(zombie, player_center)
875
815
 
876
816
 
817
+ def zombie_wander_movement(
818
+ zombie: Zombie,
819
+ _player_center: tuple[int, int],
820
+ walls: list[Wall],
821
+ _footprints: list[dict[str, object]],
822
+ cell_size: int,
823
+ grid_cols: int,
824
+ grid_rows: int,
825
+ outer_wall_cells: set[tuple[int, int]] | None,
826
+ ) -> tuple[float, float]:
827
+ return _zombie_wander_move(
828
+ zombie,
829
+ walls,
830
+ cell_size=cell_size,
831
+ grid_cols=grid_cols,
832
+ grid_rows=grid_rows,
833
+ outer_wall_cells=outer_wall_cells,
834
+ )
835
+
836
+
877
837
  def zombie_wall_follow_has_wall(
878
838
  zombie: Zombie,
879
839
  walls: list[Wall],
@@ -889,12 +849,12 @@ def zombie_wall_follow_has_wall(
889
849
  and abs(wall.rect.centery - check_y) < 120
890
850
  ]
891
851
  return any(
892
- circle_wall_collision((check_x, check_y), zombie.radius, wall)
852
+ _circle_wall_collision((check_x, check_y), zombie.radius, wall)
893
853
  for wall in candidates
894
854
  )
895
855
 
896
856
 
897
- def zombie_wall_follow_wall_distance(
857
+ def _zombie_wall_follow_wall_distance(
898
858
  zombie: Zombie,
899
859
  walls: list[Wall],
900
860
  angle: float,
@@ -918,7 +878,7 @@ def zombie_wall_follow_wall_distance(
918
878
  check_x = zombie.x + direction_x * distance
919
879
  check_y = zombie.y + direction_y * distance
920
880
  if any(
921
- circle_wall_collision((check_x, check_y), zombie.radius, wall)
881
+ _circle_wall_collision((check_x, check_y), zombie.radius, wall)
922
882
  for wall in candidates
923
883
  ):
924
884
  return distance
@@ -926,11 +886,15 @@ def zombie_wall_follow_wall_distance(
926
886
  return max_distance
927
887
 
928
888
 
929
- def zombie_wall_follow_movement(
889
+ def _zombie_wall_follow_movement(
930
890
  zombie: Zombie,
931
891
  player_center: tuple[int, int],
932
892
  walls: list[Wall],
933
893
  _footprints: list[dict[str, object]],
894
+ cell_size: int,
895
+ grid_cols: int,
896
+ grid_rows: int,
897
+ outer_wall_cells: set[tuple[int, int]] | None,
934
898
  ) -> tuple[float, float]:
935
899
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_TRACKER_SIGHT_RANGE)
936
900
  if zombie.wall_follow_angle is None:
@@ -941,13 +905,13 @@ def zombie_wall_follow_movement(
941
905
  probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
942
906
  left_angle = forward_angle + probe_offset
943
907
  right_angle = forward_angle - probe_offset
944
- left_dist = zombie_wall_follow_wall_distance(
908
+ left_dist = _zombie_wall_follow_wall_distance(
945
909
  zombie, walls, left_angle, sensor_distance
946
910
  )
947
- right_dist = zombie_wall_follow_wall_distance(
911
+ right_dist = _zombie_wall_follow_wall_distance(
948
912
  zombie, walls, right_angle, sensor_distance
949
913
  )
950
- forward_dist = zombie_wall_follow_wall_distance(
914
+ forward_dist = _zombie_wall_follow_wall_distance(
951
915
  zombie, walls, forward_angle, sensor_distance
952
916
  )
953
917
  left_wall = left_dist < sensor_distance
@@ -967,15 +931,22 @@ def zombie_wall_follow_movement(
967
931
  else:
968
932
  if is_in_sight:
969
933
  return zombie_move_toward(zombie, player_center)
970
- return zombie_wander_move(zombie, walls)
934
+ return _zombie_wander_move(
935
+ zombie,
936
+ walls,
937
+ cell_size=cell_size,
938
+ grid_cols=grid_cols,
939
+ grid_rows=grid_rows,
940
+ outer_wall_cells=outer_wall_cells,
941
+ )
971
942
 
972
943
  sensor_distance = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius
973
944
  probe_offset = math.radians(ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG)
974
945
  side_angle = zombie.wall_follow_angle + zombie.wall_follow_side * probe_offset
975
- side_dist = zombie_wall_follow_wall_distance(
946
+ side_dist = _zombie_wall_follow_wall_distance(
976
947
  zombie, walls, side_angle, sensor_distance
977
948
  )
978
- forward_dist = zombie_wall_follow_wall_distance(
949
+ forward_dist = _zombie_wall_follow_wall_distance(
979
950
  zombie, walls, zombie.wall_follow_angle, sensor_distance
980
951
  )
981
952
  side_has_wall = side_dist < sensor_distance
@@ -1024,14 +995,25 @@ def zombie_normal_movement(
1024
995
  player_center: tuple[int, int],
1025
996
  walls: list[Wall],
1026
997
  _footprints: list[dict[str, object]],
998
+ cell_size: int,
999
+ grid_cols: int,
1000
+ grid_rows: int,
1001
+ outer_wall_cells: set[tuple[int, int]] | None,
1027
1002
  ) -> tuple[float, float]:
1028
1003
  is_in_sight = zombie._update_mode(player_center, ZOMBIE_SIGHT_RANGE)
1029
1004
  if not is_in_sight:
1030
- return zombie_wander_move(zombie, walls)
1005
+ return _zombie_wander_move(
1006
+ zombie,
1007
+ walls,
1008
+ cell_size=cell_size,
1009
+ grid_cols=grid_cols,
1010
+ grid_rows=grid_rows,
1011
+ outer_wall_cells=outer_wall_cells,
1012
+ )
1031
1013
  return zombie_move_toward(zombie, player_center)
1032
1014
 
1033
1015
 
1034
- def zombie_update_tracker_target(
1016
+ def _zombie_update_tracker_target(
1035
1017
  zombie: Zombie, footprints: list[dict[str, object]]
1036
1018
  ) -> None:
1037
1019
  zombie.tracker_target_pos = None
@@ -1044,7 +1026,10 @@ def zombie_update_tracker_target(
1044
1026
  continue
1045
1027
  dx = pos[0] - zombie.x
1046
1028
  dy = pos[1] - zombie.y
1047
- if math.hypot(dx, dy) <= ZOMBIE_TRACKER_SCENT_RADIUS:
1029
+ if (
1030
+ dx * dx + dy * dy
1031
+ <= ZOMBIE_TRACKER_SCENT_RADIUS * ZOMBIE_TRACKER_SCENT_RADIUS
1032
+ ):
1048
1033
  nearby.append(fp)
1049
1034
 
1050
1035
  if not nearby:
@@ -1052,16 +1037,22 @@ def zombie_update_tracker_target(
1052
1037
 
1053
1038
  latest = max(
1054
1039
  nearby,
1055
- key=lambda fp: fp.get("time", -1)
1056
- if isinstance(fp.get("time"), int)
1057
- else -1,
1040
+ key=lambda fp: fp.get("time", -1) if isinstance(fp.get("time"), int) else -1,
1058
1041
  )
1059
1042
  pos = latest.get("pos")
1060
1043
  if isinstance(pos, tuple):
1061
1044
  zombie.tracker_target_pos = pos
1062
1045
 
1063
1046
 
1064
- def zombie_wander_move(zombie: Zombie, walls: list[Wall]) -> tuple[float, float]:
1047
+ def _zombie_wander_move(
1048
+ zombie: Zombie,
1049
+ walls: list[Wall],
1050
+ *,
1051
+ cell_size: int,
1052
+ grid_cols: int,
1053
+ grid_rows: int,
1054
+ outer_wall_cells: set[tuple[int, int]] | None,
1055
+ ) -> tuple[float, float]:
1065
1056
  now = pygame.time.get_ticks()
1066
1057
  if now - zombie.last_wander_change_time > zombie.wander_change_interval:
1067
1058
  zombie.wander_angle = RNG.uniform(0, math.tau)
@@ -1069,24 +1060,29 @@ def zombie_wander_move(zombie: Zombie, walls: list[Wall]) -> tuple[float, float]
1069
1060
  jitter = RNG.randint(-500, 500)
1070
1061
  zombie.wander_change_interval = max(0, zombie.wander_interval_ms + jitter)
1071
1062
 
1072
- cell_x = int(zombie.x // CELL_SIZE)
1073
- cell_y = int(zombie.y // CELL_SIZE)
1074
- at_x_edge = cell_x in (0, GRID_COLS - 1)
1075
- at_y_edge = cell_y in (0, GRID_ROWS - 1)
1063
+ cell_x = int(zombie.x // cell_size)
1064
+ cell_y = int(zombie.y // cell_size)
1065
+ at_x_edge = cell_x in (0, grid_cols - 1)
1066
+ at_y_edge = cell_y in (0, grid_rows - 1)
1076
1067
 
1077
1068
  if at_x_edge or at_y_edge:
1078
- if zombie.outer_wall_cells is not None:
1069
+ if outer_wall_cells is not None:
1079
1070
  if at_x_edge:
1080
- inward_cell = (1, cell_y) if cell_x == 0 else (GRID_COLS - 2, cell_y)
1081
- if inward_cell not in zombie.outer_wall_cells:
1071
+ inward_cell = (
1072
+ (1, cell_y) if cell_x == 0 else (grid_cols - 2, cell_y)
1073
+ )
1074
+ if inward_cell not in outer_wall_cells:
1082
1075
  inward_dx = zombie.speed if cell_x == 0 else -zombie.speed
1083
1076
  return inward_dx, 0.0
1084
1077
  if at_y_edge:
1085
- inward_cell = (cell_x, 1) if cell_y == 0 else (cell_x, GRID_ROWS - 2)
1086
- if inward_cell not in zombie.outer_wall_cells:
1078
+ inward_cell = (
1079
+ (cell_x, 1) if cell_y == 0 else (cell_x, grid_rows - 2)
1080
+ )
1081
+ if inward_cell not in outer_wall_cells:
1087
1082
  inward_dy = zombie.speed if cell_y == 0 else -zombie.speed
1088
1083
  return 0.0, inward_dy
1089
1084
  else:
1085
+
1090
1086
  def path_clear(next_x: float, next_y: float) -> bool:
1091
1087
  nearby_walls = [
1092
1088
  wall
@@ -1095,7 +1091,7 @@ def zombie_wander_move(zombie: Zombie, walls: list[Wall]) -> tuple[float, float]
1095
1091
  and abs(wall.rect.centery - next_y) < 120
1096
1092
  ]
1097
1093
  return not any(
1098
- circle_wall_collision((next_x, next_y), zombie.radius, wall)
1094
+ _circle_wall_collision((next_x, next_y), zombie.radius, wall)
1099
1095
  for wall in nearby_walls
1100
1096
  )
1101
1097
 
@@ -1133,43 +1129,24 @@ def zombie_move_toward(
1133
1129
  class Zombie(pygame.sprite.Sprite):
1134
1130
  def __init__(
1135
1131
  self: Self,
1132
+ x: float,
1133
+ y: float,
1136
1134
  *,
1137
- start_pos: tuple[int, int] | None = None,
1138
- hint_pos: tuple[float, float] | None = None,
1139
1135
  speed: float = ZOMBIE_SPEED,
1140
1136
  tracker: bool = False,
1141
1137
  wall_follower: bool = False,
1142
1138
  movement_strategy: MovementStrategy | None = None,
1143
1139
  aging_duration_frames: float = ZOMBIE_AGING_DURATION_FRAMES,
1144
- outer_wall_cells: set[tuple[int, int]] | None = None,
1145
1140
  ) -> None:
1146
1141
  super().__init__()
1147
1142
  self.radius = ZOMBIE_RADIUS
1148
- self.image = pygame.Surface((self.radius * 2, self.radius * 2), pygame.SRCALPHA)
1149
- if tracker:
1150
- outline_color = TRACKER_OUTLINE_COLOR
1151
- elif wall_follower:
1152
- outline_color = WALL_FOLLOWER_OUTLINE_COLOR
1153
- else:
1154
- outline_color = DARK_RED
1155
- _draw_outlined_circle(
1156
- self.image,
1157
- (self.radius, self.radius),
1158
- self.radius,
1159
- RED,
1160
- outline_color,
1161
- 1,
1143
+ self.tracker = tracker
1144
+ self.wall_follower = wall_follower
1145
+ self.carbonized = False
1146
+ self.image = build_zombie_surface(
1147
+ self.radius, tracker=self.tracker, wall_follower=self.wall_follower
1162
1148
  )
1163
- if start_pos:
1164
- x, y = start_pos
1165
- elif hint_pos:
1166
- points = [random_position_outside_building() for _ in range(5)]
1167
- points.sort(
1168
- key=lambda p: math.hypot(p[0] - hint_pos[0], p[1] - hint_pos[1])
1169
- )
1170
- x, y = points[0]
1171
- else:
1172
- x, y = random_position_outside_building()
1149
+ self._redraw_image()
1173
1150
  self.rect = self.image.get_rect(center=(x, y))
1174
1151
  jitter_base = FAST_ZOMBIE_BASE_SPEED if speed > ZOMBIE_SPEED else ZOMBIE_SPEED
1175
1152
  jitter = jitter_base * 0.2
@@ -1179,27 +1156,23 @@ class Zombie(pygame.sprite.Sprite):
1179
1156
  self.x = float(self.rect.centerx)
1180
1157
  self.y = float(self.rect.centery)
1181
1158
  self.was_in_sight = False
1182
- self.carbonized = False
1183
1159
  self.age_frames = 0.0
1184
1160
  self.aging_duration_frames = aging_duration_frames
1185
- self.tracker = tracker
1186
- self.wall_follower = wall_follower
1187
1161
  if movement_strategy is None:
1188
1162
  if tracker:
1189
- movement_strategy = zombie_tracker_movement
1163
+ movement_strategy = _zombie_tracker_movement
1190
1164
  elif wall_follower:
1191
- movement_strategy = zombie_wall_follow_movement
1165
+ movement_strategy = _zombie_wall_follow_movement
1192
1166
  else:
1193
1167
  movement_strategy = zombie_normal_movement
1194
1168
  self.movement_strategy = movement_strategy
1195
- self.outer_wall_cells = outer_wall_cells
1196
1169
  self.tracker_target_pos: tuple[float, float] | None = None
1197
1170
  self.wall_follow_side = RNG.choice([-1.0, 1.0]) if wall_follower else 0.0
1198
- self.wall_follow_angle = (
1199
- RNG.uniform(0, math.tau) if wall_follower else None
1200
- )
1171
+ self.wall_follow_angle = RNG.uniform(0, math.tau) if wall_follower else None
1201
1172
  self.wall_follow_last_wall_time: int | None = None
1202
1173
  self.wall_follow_last_side_has_wall = False
1174
+ self.wall_follow_stuck_flag = False
1175
+ self.pos_history: list[tuple[float, float]] = []
1203
1176
  self.wander_angle = RNG.uniform(0, math.tau)
1204
1177
  self.wander_interval_ms = (
1205
1178
  ZOMBIE_TRACKER_WANDER_INTERVAL_MS if tracker else ZOMBIE_WANDER_INTERVAL_MS
@@ -1209,13 +1182,22 @@ class Zombie(pygame.sprite.Sprite):
1209
1182
  0, self.wander_interval_ms + RNG.randint(-500, 500)
1210
1183
  )
1211
1184
 
1185
+ def _redraw_image(self: Self, palm_angle: float | None = None) -> None:
1186
+ paint_zombie_surface(
1187
+ self.image,
1188
+ radius=self.radius,
1189
+ palm_angle=palm_angle,
1190
+ tracker=self.tracker,
1191
+ wall_follower=self.wall_follower,
1192
+ )
1193
+
1212
1194
  def _update_mode(
1213
1195
  self: Self, player_center: tuple[int, int], sight_range: float
1214
1196
  ) -> bool:
1215
1197
  dx_target = player_center[0] - self.x
1216
1198
  dy_target = player_center[1] - self.y
1217
- dist_to_player = math.hypot(dx_target, dy_target)
1218
- is_in_sight = dist_to_player <= sight_range
1199
+ dist_to_player_sq = dx_target * dx_target + dy_target * dy_target
1200
+ is_in_sight = dist_to_player_sq <= sight_range * sight_range
1219
1201
  self.was_in_sight = is_in_sight
1220
1202
  return is_in_sight
1221
1203
 
@@ -1231,25 +1213,64 @@ class Zombie(pygame.sprite.Sprite):
1231
1213
  ]
1232
1214
 
1233
1215
  for wall in possible_walls:
1234
- collides = circle_wall_collision((next_x, self.y), self.radius, wall)
1216
+ collides = _circle_wall_collision((next_x, self.y), self.radius, wall)
1235
1217
  if collides:
1236
1218
  if wall.alive():
1237
- wall.take_damage(amount=ZOMBIE_WALL_DAMAGE)
1219
+ wall._take_damage(amount=ZOMBIE_WALL_DAMAGE)
1238
1220
  if wall.alive():
1239
1221
  final_x = self.x
1240
1222
  break
1241
1223
 
1242
1224
  for wall in possible_walls:
1243
- collides = circle_wall_collision((final_x, next_y), self.radius, wall)
1225
+ collides = _circle_wall_collision((final_x, next_y), self.radius, wall)
1244
1226
  if collides:
1245
1227
  if wall.alive():
1246
- wall.take_damage(amount=ZOMBIE_WALL_DAMAGE)
1228
+ wall._take_damage(amount=ZOMBIE_WALL_DAMAGE)
1247
1229
  if wall.alive():
1248
1230
  final_y = self.y
1249
1231
  break
1250
1232
 
1233
+ for wall in possible_walls:
1234
+ final_x, final_y = self._apply_bevel_corner_repulsion(
1235
+ final_x, final_y, wall
1236
+ )
1237
+
1251
1238
  return final_x, final_y
1252
1239
 
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
+
1253
1274
  def _avoid_other_zombies(
1254
1275
  self: Self,
1255
1276
  move_x: float,
@@ -1261,7 +1282,7 @@ class Zombie(pygame.sprite.Sprite):
1261
1282
  next_y = self.y + move_y
1262
1283
 
1263
1284
  closest: Zombie | None = None
1264
- closest_dist = ZOMBIE_SEPARATION_DISTANCE
1285
+ closest_dist_sq = ZOMBIE_SEPARATION_DISTANCE * ZOMBIE_SEPARATION_DISTANCE
1265
1286
  for other in zombies:
1266
1287
  if other is self or not other.alive():
1267
1288
  continue
@@ -1272,14 +1293,27 @@ class Zombie(pygame.sprite.Sprite):
1272
1293
  or abs(dy) > ZOMBIE_SEPARATION_DISTANCE
1273
1294
  ):
1274
1295
  continue
1275
- dist = math.hypot(dx, dy)
1276
- if dist < closest_dist:
1296
+ dist_sq = dx * dx + dy * dy
1297
+ if dist_sq < closest_dist_sq:
1277
1298
  closest = other
1278
- closest_dist = dist
1299
+ closest_dist_sq = dist_sq
1279
1300
 
1280
1301
  if closest is None:
1281
1302
  return move_x, move_y
1282
1303
 
1304
+ if self.wall_follower:
1305
+ other_radius = float(getattr(closest, "radius", self.radius))
1306
+ bump_dist_sq = (self.radius + other_radius) ** 2
1307
+ if closest_dist_sq < bump_dist_sq and RNG.random() < 0.1:
1308
+ if self.wall_follow_angle is None:
1309
+ self.wall_follow_angle = self.wander_angle
1310
+ self.wall_follow_angle = (self.wall_follow_angle + math.pi) % math.tau
1311
+ self.wall_follow_side *= -1.0
1312
+ return (
1313
+ math.cos(self.wall_follow_angle) * self.speed,
1314
+ math.sin(self.wall_follow_angle) * self.speed,
1315
+ )
1316
+
1283
1317
  away_dx = next_x - closest.x
1284
1318
  away_dy = next_y - closest.y
1285
1319
  away_dist = math.hypot(away_dx, away_dy)
@@ -1302,31 +1336,80 @@ class Zombie(pygame.sprite.Sprite):
1302
1336
  slowdown_ratio = 1.0 - progress * (1.0 - ZOMBIE_AGING_MIN_SPEED_RATIO)
1303
1337
  self.speed = self.initial_speed * slowdown_ratio
1304
1338
 
1339
+ def _update_stuck_state(self: Self) -> None:
1340
+ history = self.pos_history
1341
+ history.append((self.x, self.y))
1342
+ if len(history) > 20:
1343
+ history.pop(0)
1344
+ max_dist_sq = max(
1345
+ (self.x - hx) ** 2 + (self.y - hy) ** 2 for hx, hy in history
1346
+ )
1347
+ self.wall_follow_stuck_flag = max_dist_sq < 25
1348
+ if not self.wall_follow_stuck_flag:
1349
+ return
1350
+ if self.wall_follower:
1351
+ if self.wall_follow_angle is None:
1352
+ self.wall_follow_angle = self.wander_angle
1353
+ self.wall_follow_angle = (self.wall_follow_angle + math.pi) % math.tau
1354
+ self.wall_follow_side *= -1.0
1355
+ self.wall_follow_stuck_flag = False
1356
+ self.pos_history = []
1357
+
1305
1358
  def update(
1306
1359
  self: Self,
1307
1360
  player_center: tuple[int, int],
1308
1361
  walls: list[Wall],
1309
1362
  nearby_zombies: Iterable[Zombie],
1310
1363
  footprints: list[dict[str, object]] | None = None,
1364
+ *,
1365
+ cell_size: int,
1366
+ grid_cols: int,
1367
+ grid_rows: int,
1368
+ level_width: int,
1369
+ level_height: int,
1370
+ outer_wall_cells: set[tuple[int, int]] | None = None,
1311
1371
  ) -> None:
1312
1372
  if self.carbonized:
1313
1373
  return
1374
+ self._update_stuck_state()
1314
1375
  self._apply_aging()
1315
1376
  dx_player = player_center[0] - self.x
1316
1377
  dy_player = player_center[1] - self.y
1317
- dist_to_player = math.hypot(dx_player, dy_player)
1378
+ dist_to_player_sq = dx_player * dx_player + dy_player * dy_player
1318
1379
  avoid_radius = max(SCREEN_WIDTH, SCREEN_HEIGHT) * 2
1380
+ avoid_radius_sq = avoid_radius * avoid_radius
1319
1381
  move_x, move_y = self.movement_strategy(
1320
- self, player_center, walls, footprints or []
1382
+ self,
1383
+ player_center,
1384
+ walls,
1385
+ footprints or [],
1386
+ cell_size,
1387
+ grid_cols,
1388
+ grid_rows,
1389
+ outer_wall_cells,
1321
1390
  )
1322
- if dist_to_player <= avoid_radius:
1391
+ if dist_to_player_sq <= avoid_radius_sq or self.wall_follower:
1323
1392
  move_x, move_y = self._avoid_other_zombies(move_x, move_y, nearby_zombies)
1393
+ if self.wall_follower and self.wall_follow_side != 0:
1394
+ if move_x != 0 or move_y != 0:
1395
+ heading = math.atan2(move_y, move_x)
1396
+ elif self.wall_follow_angle is not None:
1397
+ heading = self.wall_follow_angle
1398
+ else:
1399
+ heading = self.wander_angle
1400
+ if self.wall_follow_side > 0:
1401
+ palm_angle = heading + (math.pi / 2.0)
1402
+ else:
1403
+ palm_angle = heading - (math.pi / 2.0)
1404
+ self._redraw_image(palm_angle)
1324
1405
  final_x, final_y = self._handle_wall_collision(
1325
1406
  self.x + move_x, self.y + move_y, walls
1326
1407
  )
1327
1408
 
1328
- if not (0 <= final_x < LEVEL_WIDTH and 0 <= final_y < LEVEL_HEIGHT):
1329
- final_x, final_y = random_position_outside_building()
1409
+ 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
+ )
1330
1413
 
1331
1414
  self.x = final_x
1332
1415
  self.y = final_y
@@ -1346,24 +1429,10 @@ class Zombie(pygame.sprite.Sprite):
1346
1429
 
1347
1430
 
1348
1431
  class Car(pygame.sprite.Sprite):
1349
- COLOR_SCHEMES: dict[str, dict[str, tuple[int, int, int]]] = {
1350
- "default": {
1351
- "healthy": YELLOW,
1352
- "damaged": ORANGE,
1353
- "critical": DARK_RED,
1354
- },
1355
- "disabled": {
1356
- "healthy": (185, 185, 185),
1357
- "damaged": (150, 150, 150),
1358
- "critical": (110, 110, 110),
1359
- },
1360
- }
1361
-
1362
1432
  def __init__(self: Self, x: int, y: int, *, appearance: str = "default") -> None:
1363
1433
  super().__init__()
1364
- self.original_image = pygame.Surface((CAR_WIDTH, CAR_HEIGHT), pygame.SRCALPHA)
1365
- self.appearance = appearance if appearance in self.COLOR_SCHEMES else "default"
1366
- self.base_color = self.COLOR_SCHEMES[self.appearance]["healthy"]
1434
+ self.original_image = build_car_surface(CAR_WIDTH, CAR_HEIGHT)
1435
+ self.appearance = appearance
1367
1436
  self.image = self.original_image.copy()
1368
1437
  self.rect = self.image.get_rect(center=(x, y))
1369
1438
  self.speed = CAR_SPEED
@@ -1372,85 +1441,23 @@ class Car(pygame.sprite.Sprite):
1372
1441
  self.y = float(self.rect.centery)
1373
1442
  self.health = CAR_HEALTH
1374
1443
  self.max_health = CAR_HEALTH
1375
- self.collision_radius = car_body_radius(CAR_WIDTH, CAR_HEIGHT)
1376
- self.update_color()
1444
+ self.collision_radius = _car_body_radius(CAR_WIDTH, CAR_HEIGHT)
1445
+ self._update_color()
1377
1446
 
1378
- def take_damage(self: Self, amount: int) -> None:
1447
+ def _take_damage(self: Self, amount: int) -> None:
1379
1448
  if self.health > 0:
1380
1449
  self.health -= amount
1381
- self.update_color()
1450
+ self._update_color()
1382
1451
 
1383
- def update_color(self: Self) -> None:
1452
+ def _update_color(self: Self) -> None:
1384
1453
  health_ratio = max(0, self.health / self.max_health)
1385
- palette = self.COLOR_SCHEMES.get(self.appearance, self.COLOR_SCHEMES["default"])
1386
- color = palette["healthy"]
1387
- if health_ratio < 0.6:
1388
- color = palette["damaged"]
1389
- if health_ratio < 0.3:
1390
- color = palette["critical"]
1391
- self.original_image.fill((0, 0, 0, 0))
1392
-
1393
- body_rect = pygame.Rect(1, 4, CAR_WIDTH - 2, CAR_HEIGHT - 8)
1394
- front_cap_height = max(8, body_rect.height // 3)
1395
- front_cap = pygame.Rect(
1396
- body_rect.left, body_rect.top, body_rect.width, front_cap_height
1454
+ color = resolve_car_color(health_ratio=health_ratio, appearance=self.appearance)
1455
+ paint_car_surface(
1456
+ self.original_image,
1457
+ width=CAR_WIDTH,
1458
+ height=CAR_HEIGHT,
1459
+ color=color,
1397
1460
  )
1398
- windshield_rect = pygame.Rect(
1399
- body_rect.left + 4,
1400
- body_rect.top + 3,
1401
- body_rect.width - 8,
1402
- front_cap_height - 5,
1403
- )
1404
-
1405
- trim_color = tuple(int(c * 0.55) for c in color)
1406
- front_cap_color = tuple(min(255, int(c * 1.08)) for c in color)
1407
- body_color = color
1408
- window_color = (70, 110, 150)
1409
- wheel_color = (35, 35, 35)
1410
-
1411
- wheel_width = CAR_WIDTH // 3
1412
- wheel_height = 6
1413
- for y in (body_rect.top + 4, body_rect.bottom - wheel_height - 4):
1414
- left_wheel = pygame.Rect(2, y, wheel_width, wheel_height)
1415
- right_wheel = pygame.Rect(
1416
- CAR_WIDTH - wheel_width - 2, y, wheel_width, wheel_height
1417
- )
1418
- pygame.draw.rect(
1419
- self.original_image, wheel_color, left_wheel, border_radius=3
1420
- )
1421
- pygame.draw.rect(
1422
- self.original_image, wheel_color, right_wheel, border_radius=3
1423
- )
1424
-
1425
- pygame.draw.rect(self.original_image, body_color, body_rect, border_radius=4)
1426
- pygame.draw.rect(
1427
- self.original_image, trim_color, body_rect, width=2, border_radius=4
1428
- )
1429
- pygame.draw.rect(
1430
- self.original_image, front_cap_color, front_cap, border_radius=10
1431
- )
1432
- pygame.draw.rect(
1433
- self.original_image, trim_color, front_cap, width=2, border_radius=10
1434
- )
1435
- pygame.draw.rect(
1436
- self.original_image, window_color, windshield_rect, border_radius=4
1437
- )
1438
-
1439
- headlight_color = (245, 245, 200)
1440
- for x in (front_cap.left + 5, front_cap.right - 5):
1441
- pygame.draw.circle(
1442
- self.original_image, headlight_color, (x, body_rect.top + 5), 2
1443
- )
1444
- grille_rect = pygame.Rect(front_cap.centerx - 6, front_cap.top + 2, 12, 6)
1445
- pygame.draw.rect(self.original_image, trim_color, grille_rect, border_radius=2)
1446
- tail_light_color = (255, 80, 50)
1447
- for x in (body_rect.left + 5, body_rect.right - 5):
1448
- pygame.draw.rect(
1449
- self.original_image,
1450
- tail_light_color,
1451
- (x - 2, body_rect.bottom - 5, 4, 3),
1452
- border_radius=1,
1453
- )
1454
1461
  self.image = pygame.transform.rotate(self.original_image, self.angle)
1455
1462
  old_center = self.rect.center
1456
1463
  self.rect = self.image.get_rect(center=old_center)
@@ -1477,10 +1484,10 @@ class Car(pygame.sprite.Sprite):
1477
1484
  ]
1478
1485
  car_center = (new_x, new_y)
1479
1486
  for wall in possible_walls:
1480
- if circle_wall_collision(car_center, self.collision_radius, wall):
1487
+ if _circle_wall_collision(car_center, self.collision_radius, wall):
1481
1488
  hit_walls.append(wall)
1482
1489
  if hit_walls:
1483
- self.take_damage(CAR_WALL_DAMAGE)
1490
+ self._take_damage(CAR_WALL_DAMAGE)
1484
1491
  hit_walls.sort(
1485
1492
  key=lambda w: (w.rect.centery - self.y) ** 2
1486
1493
  + (w.rect.centerx - self.x) ** 2
@@ -1499,31 +1506,7 @@ class FuelCan(pygame.sprite.Sprite):
1499
1506
 
1500
1507
  def __init__(self: Self, x: int, y: int) -> None:
1501
1508
  super().__init__()
1502
- self.image = pygame.Surface((FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT), pygame.SRCALPHA)
1503
-
1504
- # Jerrycan silhouette with cut corner
1505
- w, h = FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT
1506
- body_pts = [
1507
- (1, 4),
1508
- (w - 2, 4),
1509
- (w - 2, h - 2),
1510
- (1, h - 2),
1511
- (1, 8),
1512
- (4, 4),
1513
- ]
1514
- pygame.draw.polygon(self.image, YELLOW, body_pts)
1515
- pygame.draw.polygon(self.image, BLACK, body_pts, width=2)
1516
-
1517
- cap_size = max(2, w // 4)
1518
- cap_rect = pygame.Rect(w - cap_size - 2, 1, cap_size, 3)
1519
- pygame.draw.rect(self.image, YELLOW, cap_rect, border_radius=1)
1520
- pygame.draw.rect(self.image, BLACK, cap_rect, width=1, border_radius=1)
1521
-
1522
- # Cross brace accent
1523
- brace_color = (240, 200, 40)
1524
- pygame.draw.line(self.image, brace_color, (3, h // 2), (w - 4, h // 2), width=2)
1525
- pygame.draw.line(self.image, BLACK, (3, h // 2), (w - 4, h // 2), width=1)
1526
-
1509
+ self.image = build_fuel_can_surface(FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT)
1527
1510
  self.rect = self.image.get_rect(center=(x, y))
1528
1511
 
1529
1512
 
@@ -1532,32 +1515,13 @@ class Flashlight(pygame.sprite.Sprite):
1532
1515
 
1533
1516
  def __init__(self: Self, x: int, y: int) -> None:
1534
1517
  super().__init__()
1535
- self.image = pygame.Surface(
1536
- (FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT), pygame.SRCALPHA
1537
- )
1538
-
1539
- body_color = (230, 200, 70)
1540
- trim_color = (80, 70, 40)
1541
- head_color = (200, 180, 90)
1542
- beam_color = (255, 240, 180, 150)
1543
-
1544
- body_rect = pygame.Rect(1, 2, FLASHLIGHT_WIDTH - 4, FLASHLIGHT_HEIGHT - 4)
1545
- head_rect = pygame.Rect(
1546
- body_rect.right - 3, body_rect.top - 1, 4, body_rect.height + 2
1547
- )
1548
- beam_points = [
1549
- (head_rect.right + 4, head_rect.centery),
1550
- (head_rect.right + 2, head_rect.top),
1551
- (head_rect.right + 2, head_rect.bottom),
1552
- ]
1518
+ self.image = build_flashlight_surface(FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT)
1519
+ self.rect = self.image.get_rect(center=(x, y))
1553
1520
 
1554
- pygame.draw.rect(self.image, body_color, body_rect, border_radius=2)
1555
- pygame.draw.rect(self.image, trim_color, body_rect, width=1, border_radius=2)
1556
- pygame.draw.rect(self.image, head_color, head_rect, border_radius=2)
1557
- pygame.draw.rect(self.image, trim_color, head_rect, width=1, border_radius=2)
1558
- pygame.draw.polygon(self.image, beam_color, beam_points)
1559
1521
 
1560
- self.rect = self.image.get_rect(center=(x, y))
1522
+ def _car_body_radius(width: float, height: float) -> float:
1523
+ """Approximate car collision radius using only its own dimensions."""
1524
+ return min(width, height) / 2
1561
1525
 
1562
1526
 
1563
1527
  __all__ = [