zombie-escape 1.12.0__py3-none-any.whl → 1.13.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 (34) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/__main__.py +7 -0
  3. zombie_escape/colors.py +22 -14
  4. zombie_escape/entities.py +756 -147
  5. zombie_escape/entities_constants.py +35 -14
  6. zombie_escape/export_images.py +296 -0
  7. zombie_escape/gameplay/__init__.py +2 -1
  8. zombie_escape/gameplay/constants.py +6 -0
  9. zombie_escape/gameplay/footprints.py +4 -0
  10. zombie_escape/gameplay/interactions.py +19 -7
  11. zombie_escape/gameplay/layout.py +103 -34
  12. zombie_escape/gameplay/movement.py +85 -5
  13. zombie_escape/gameplay/spawn.py +139 -90
  14. zombie_escape/gameplay/state.py +18 -9
  15. zombie_escape/gameplay/survivors.py +13 -2
  16. zombie_escape/gameplay/utils.py +40 -21
  17. zombie_escape/level_blueprints.py +256 -19
  18. zombie_escape/locales/ui.en.json +12 -2
  19. zombie_escape/locales/ui.ja.json +12 -2
  20. zombie_escape/models.py +14 -7
  21. zombie_escape/render.py +149 -37
  22. zombie_escape/render_assets.py +419 -124
  23. zombie_escape/render_constants.py +27 -0
  24. zombie_escape/screens/game_over.py +14 -3
  25. zombie_escape/screens/gameplay.py +72 -14
  26. zombie_escape/screens/title.py +18 -7
  27. zombie_escape/stage_constants.py +51 -15
  28. zombie_escape/zombie_escape.py +24 -1
  29. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/METADATA +41 -15
  30. zombie_escape-1.13.1.dist-info/RECORD +49 -0
  31. zombie_escape-1.12.0.dist-info/RECORD +0 -47
  32. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/WHEEL +0 -0
  33. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/entry_points.txt +0 -0
  34. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -12,20 +12,22 @@ from .colors import (
12
12
  BLUE,
13
13
  DARK_RED,
14
14
  ORANGE,
15
- RED,
16
15
  STEEL_BEAM_COLOR,
17
16
  STEEL_BEAM_LINE_COLOR,
18
- TRACKER_OUTLINE_COLOR,
19
- WALL_FOLLOWER_OUTLINE_COLOR,
20
17
  YELLOW,
21
18
  EnvironmentPalette,
22
19
  get_environment_palette,
23
20
  )
21
+ from .entities_constants import INTERNAL_WALL_BEVEL_DEPTH
24
22
  from .render_constants import (
23
+ ANGLE_BINS,
25
24
  BUDDY_COLOR,
25
+ HAND_SPREAD_RAD,
26
26
  HUMANOID_OUTLINE_COLOR,
27
27
  HUMANOID_OUTLINE_WIDTH,
28
28
  SURVIVOR_COLOR,
29
+ ZOMBIE_BODY_COLOR,
30
+ ZOMBIE_OUTLINE_COLOR,
29
31
  FogRing,
30
32
  RenderAssets,
31
33
  )
@@ -44,6 +46,118 @@ def _draw_outlined_circle(
44
46
  pygame.draw.circle(surface, outline_color, center, radius, width=outline_width)
45
47
 
46
48
 
49
+ def _brighten_color(
50
+ color: tuple[int, int, int], *, factor: float = 1.25
51
+ ) -> tuple[int, int, int]:
52
+ return tuple(min(255, int(c * factor + 0.5)) for c in color)
53
+
54
+
55
+ ANGLE_STEP = math.tau / ANGLE_BINS
56
+
57
+ _PLAYER_UPSCALE_FACTOR = 4
58
+ _CAR_UPSCALE_FACTOR = 4
59
+
60
+ _PLAYER_DIRECTIONAL_CACHE: dict[tuple[int, int], list[pygame.Surface]] = {}
61
+ _SURVIVOR_DIRECTIONAL_CACHE: dict[
62
+ tuple[int, bool, bool, int], list[pygame.Surface]
63
+ ] = {}
64
+ _ZOMBIE_DIRECTIONAL_CACHE: dict[tuple[int, bool, int], list[pygame.Surface]] = {}
65
+ _RUBBLE_SURFACE_CACHE: dict[tuple, pygame.Surface] = {}
66
+
67
+ RUBBLE_ROTATION_DEG = 5.0
68
+ RUBBLE_OFFSET_RATIO = 0.06
69
+ RUBBLE_SCALE_RATIO = 0.9
70
+ RUBBLE_SHADOW_RATIO = 0.9
71
+
72
+
73
+ def _scale_color(
74
+ color: tuple[int, int, int], *, ratio: float
75
+ ) -> tuple[int, int, int]:
76
+ return tuple(max(0, min(255, int(c * ratio + 0.5))) for c in color)
77
+
78
+
79
+ def rubble_offset_for_size(size: int) -> int:
80
+ return max(1, int(round(size * RUBBLE_OFFSET_RATIO)))
81
+
82
+
83
+ def angle_bin_from_vector(
84
+ dx: float, dy: float, *, bins: int = ANGLE_BINS
85
+ ) -> int | None:
86
+ if dx == 0 and dy == 0:
87
+ return None
88
+ angle = math.atan2(dy, dx)
89
+ if angle < 0:
90
+ angle += math.tau
91
+ step = math.tau / bins
92
+ return int(round(angle / step)) % bins
93
+
94
+
95
+ def _hand_defaults(radius: int) -> tuple[int, int]:
96
+ hand_radius = max(1, int(radius * 0.5))
97
+ hand_distance = max(hand_radius + 1, int(radius * 1.0))
98
+ return hand_radius, hand_distance
99
+
100
+
101
+ def _draw_capped_circle(
102
+ surface: pygame.Surface,
103
+ center: tuple[int, int],
104
+ radius: int,
105
+ base_color: tuple[int, int, int],
106
+ cap_color: tuple[int, int, int],
107
+ outline_color: tuple[int, int, int],
108
+ outline_width: int,
109
+ *,
110
+ angle_rad: float = 0.0,
111
+ hand_spread_rad: float = HAND_SPREAD_RAD,
112
+ hand_radius: int | None = None,
113
+ hand_distance: int | None = None,
114
+ draw_hands: bool = True,
115
+ ) -> None:
116
+ if hand_radius is None or hand_distance is None:
117
+ hand_radius, hand_distance = _hand_defaults(radius)
118
+ if draw_hands:
119
+ for direction in (-1, 1):
120
+ hand_angle = angle_rad + (hand_spread_rad * direction)
121
+ hand_x = int(round(center[0] + math.cos(hand_angle) * hand_distance))
122
+ hand_y = int(round(center[1] + math.sin(hand_angle) * hand_distance))
123
+ pygame.draw.circle(surface, base_color, (hand_x, hand_y), hand_radius)
124
+ pygame.draw.circle(surface, cap_color, center, radius)
125
+ if outline_width > 0:
126
+ pygame.draw.circle(surface, outline_color, center, radius, width=outline_width)
127
+
128
+
129
+ def _build_capped_surface(
130
+ radius: int,
131
+ base_color: tuple[int, int, int],
132
+ cap_color: tuple[int, int, int],
133
+ angle_bin: int,
134
+ *,
135
+ outline_scale: int = 1,
136
+ draw_hands: bool = True,
137
+ outline_color: tuple[int, int, int] = HUMANOID_OUTLINE_COLOR,
138
+ ) -> pygame.Surface:
139
+ hand_radius, hand_distance = _hand_defaults(radius)
140
+ max_extent = max(radius, hand_distance + hand_radius)
141
+ size = max_extent * 2 + 2
142
+ surface = pygame.Surface((size, size), pygame.SRCALPHA)
143
+ center = (max_extent + 1, max_extent + 1)
144
+ angle_rad = (angle_bin % ANGLE_BINS) * ANGLE_STEP
145
+ _draw_capped_circle(
146
+ surface,
147
+ center,
148
+ radius,
149
+ base_color,
150
+ cap_color,
151
+ outline_color,
152
+ HUMANOID_OUTLINE_WIDTH * outline_scale,
153
+ angle_rad=angle_rad,
154
+ hand_radius=hand_radius,
155
+ hand_distance=hand_distance,
156
+ draw_hands=draw_hands,
157
+ )
158
+ return surface
159
+
160
+
47
161
  @dataclass(frozen=True)
48
162
  class PolygonSpec:
49
163
  size: tuple[int, int]
@@ -93,7 +207,7 @@ SHOES_SPEC = PolygonSpec(
93
207
  [
94
208
  (1, 1),
95
209
  (7, 1),
96
- (7, 4),
210
+ (8, 4),
97
211
  (13, 6),
98
212
  (13, 9),
99
213
  (1, 9),
@@ -274,55 +388,145 @@ def resolve_steel_beam_colors(
274
388
  return STEEL_BEAM_COLOR, STEEL_BEAM_LINE_COLOR
275
389
 
276
390
 
277
- def build_player_surface(radius: int) -> pygame.Surface:
278
- surface = pygame.Surface((radius * 2 + 2, radius * 2 + 2), pygame.SRCALPHA)
279
- _draw_outlined_circle(
280
- surface,
281
- (radius + 1, radius + 1),
391
+ def build_player_directional_surfaces(
392
+ radius: int, *, bins: int = ANGLE_BINS
393
+ ) -> list[pygame.Surface]:
394
+ cache_key = (radius, bins)
395
+ if cache_key in _PLAYER_DIRECTIONAL_CACHE:
396
+ return _PLAYER_DIRECTIONAL_CACHE[cache_key]
397
+ surfaces = build_humanoid_directional_surfaces(
282
398
  radius,
283
- BLUE,
284
- HUMANOID_OUTLINE_COLOR,
285
- HUMANOID_OUTLINE_WIDTH,
399
+ base_color=BLUE,
400
+ cap_color=_brighten_color(BLUE),
401
+ bins=bins,
402
+ outline_color=HUMANOID_OUTLINE_COLOR,
286
403
  )
287
- return surface
404
+ _PLAYER_DIRECTIONAL_CACHE[cache_key] = surfaces
405
+ return surfaces
288
406
 
289
407
 
290
- def build_survivor_surface(radius: int, *, is_buddy: bool) -> pygame.Surface:
291
- surface = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
292
- fill_color = BUDDY_COLOR if is_buddy else SURVIVOR_COLOR
293
- _draw_outlined_circle(
408
+ def build_humanoid_directional_surfaces(
409
+ radius: int,
410
+ *,
411
+ base_color: tuple[int, int, int],
412
+ cap_color: tuple[int, int, int],
413
+ bins: int = ANGLE_BINS,
414
+ draw_hands: bool = True,
415
+ outline_color: tuple[int, int, int],
416
+ ) -> list[pygame.Surface]:
417
+ base_radius = radius * _PLAYER_UPSCALE_FACTOR
418
+ base_surface = _build_capped_surface(
419
+ base_radius,
420
+ base_color,
421
+ cap_color,
422
+ 0,
423
+ outline_scale=_PLAYER_UPSCALE_FACTOR,
424
+ draw_hands=draw_hands,
425
+ outline_color=outline_color,
426
+ )
427
+ target_surface = _build_capped_surface(
428
+ radius,
429
+ base_color,
430
+ cap_color,
431
+ 0,
432
+ draw_hands=draw_hands,
433
+ outline_color=outline_color,
434
+ )
435
+ target_size = target_surface.get_size()
436
+ scale = target_size[0] / base_surface.get_width()
437
+ half_step_deg = 360.0 / (bins * 5)
438
+ surfaces: list[pygame.Surface] = []
439
+ for idx in range(bins):
440
+ rotation_deg = -(idx * 360.0 / bins - half_step_deg)
441
+ rotated = pygame.transform.rotozoom(base_surface, rotation_deg, scale)
442
+ framed = pygame.Surface(target_size, pygame.SRCALPHA)
443
+ framed.blit(rotated, rotated.get_rect(center=framed.get_rect().center))
444
+ surfaces.append(framed)
445
+ return surfaces
446
+
447
+
448
+ def draw_humanoid_hand(
449
+ surface: pygame.Surface,
450
+ *,
451
+ radius: int,
452
+ angle_rad: float,
453
+ color: tuple[int, int, int],
454
+ hand_radius: int | None = None,
455
+ hand_distance: int | None = None,
456
+ ) -> None:
457
+ if hand_radius is None or hand_distance is None:
458
+ hand_radius, hand_distance = _hand_defaults(radius)
459
+ center_x, center_y = surface.get_rect().center
460
+ hand_x = int(round(center_x + math.cos(angle_rad) * hand_distance))
461
+ hand_y = int(round(center_y + math.sin(angle_rad) * hand_distance))
462
+ pygame.draw.circle(surface, color, (hand_x, hand_y), hand_radius)
463
+
464
+
465
+ def draw_humanoid_nose(
466
+ surface: pygame.Surface,
467
+ *,
468
+ radius: int,
469
+ angle_rad: float,
470
+ color: tuple[int, int, int],
471
+ ) -> None:
472
+ center_x, center_y = surface.get_rect().center
473
+ nose_length = max(2, int(radius * 0.45))
474
+ nose_offset = max(1, int(radius * 0.35))
475
+ start_x = center_x + math.cos(angle_rad) * nose_offset
476
+ start_y = center_y + math.sin(angle_rad) * nose_offset
477
+ end_x = center_x + math.cos(angle_rad) * (nose_offset + nose_length)
478
+ end_y = center_y + math.sin(angle_rad) * (nose_offset + nose_length)
479
+ pygame.draw.line(
294
480
  surface,
295
- (radius, radius),
481
+ color,
482
+ (int(start_x), int(start_y)),
483
+ (int(end_x), int(end_y)),
484
+ width=2,
485
+ )
486
+
487
+
488
+ def build_survivor_directional_surfaces(
489
+ radius: int,
490
+ *,
491
+ is_buddy: bool,
492
+ bins: int = ANGLE_BINS,
493
+ draw_hands: bool = True,
494
+ ) -> list[pygame.Surface]:
495
+ cache_key = (radius, is_buddy, draw_hands, bins)
496
+ if cache_key in _SURVIVOR_DIRECTIONAL_CACHE:
497
+ return _SURVIVOR_DIRECTIONAL_CACHE[cache_key]
498
+ fill_color = BUDDY_COLOR if is_buddy else SURVIVOR_COLOR
499
+ surfaces = build_humanoid_directional_surfaces(
296
500
  radius,
297
- fill_color,
298
- HUMANOID_OUTLINE_COLOR,
299
- HUMANOID_OUTLINE_WIDTH,
501
+ base_color=fill_color,
502
+ cap_color=_brighten_color(fill_color),
503
+ bins=bins,
504
+ draw_hands=draw_hands,
505
+ outline_color=HUMANOID_OUTLINE_COLOR,
300
506
  )
301
- return surface
507
+ _SURVIVOR_DIRECTIONAL_CACHE[cache_key] = surfaces
508
+ return surfaces
302
509
 
303
510
 
304
- def build_zombie_surface(
511
+ def build_zombie_directional_surfaces(
305
512
  radius: int,
306
513
  *,
307
- tracker: bool = False,
308
- wall_follower: bool = False,
309
- ) -> pygame.Surface:
310
- if tracker:
311
- outline_color = TRACKER_OUTLINE_COLOR
312
- elif wall_follower:
313
- outline_color = WALL_FOLLOWER_OUTLINE_COLOR
314
- else:
315
- outline_color = DARK_RED
316
- surface = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
317
- _draw_outlined_circle(
318
- surface,
319
- (radius, radius),
514
+ bins: int = ANGLE_BINS,
515
+ draw_hands: bool = True,
516
+ ) -> list[pygame.Surface]:
517
+ cache_key = (radius, draw_hands, bins)
518
+ if cache_key in _ZOMBIE_DIRECTIONAL_CACHE:
519
+ return _ZOMBIE_DIRECTIONAL_CACHE[cache_key]
520
+ surfaces = build_humanoid_directional_surfaces(
320
521
  radius,
321
- RED,
322
- outline_color,
323
- 1,
522
+ base_color=ZOMBIE_BODY_COLOR,
523
+ cap_color=_brighten_color(ZOMBIE_BODY_COLOR),
524
+ bins=bins,
525
+ draw_hands=draw_hands,
526
+ outline_color=ZOMBIE_OUTLINE_COLOR,
324
527
  )
325
- return surface
528
+ _ZOMBIE_DIRECTIONAL_CACHE[cache_key] = surfaces
529
+ return surfaces
326
530
 
327
531
 
328
532
  def build_car_surface(width: int, height: int) -> pygame.Surface:
@@ -336,53 +540,100 @@ def paint_car_surface(
336
540
  height: int,
337
541
  color: tuple[int, int, int],
338
542
  ) -> None:
339
- surface.fill((0, 0, 0, 0))
543
+ upscale = _CAR_UPSCALE_FACTOR
544
+ if upscale > 1:
545
+ up_width = width * upscale
546
+ up_height = height * upscale
547
+ up_surface = pygame.Surface((up_width, up_height), pygame.SRCALPHA)
548
+ _paint_car_surface_base(
549
+ up_surface, width=up_width, height=up_height, color=color
550
+ )
551
+ scaled = pygame.transform.smoothscale(up_surface, (width, height))
552
+ surface.fill((0, 0, 0, 0))
553
+ surface.blit(scaled, (0, 0))
554
+ return
555
+ _paint_car_surface_base(surface, width=width, height=height, color=color)
340
556
 
341
- body_rect = pygame.Rect(1, 4, width - 2, height - 8)
342
- front_cap_height = max(8, body_rect.height // 3)
343
- front_cap = pygame.Rect(
344
- body_rect.left, body_rect.top, body_rect.width, front_cap_height
345
- )
346
- windshield_rect = pygame.Rect(
347
- body_rect.left + 4,
348
- body_rect.top + 3,
349
- body_rect.width - 8,
350
- front_cap_height - 5,
351
- )
352
557
 
353
- trim_color = tuple(int(c * 0.55) for c in color)
354
- front_cap_color = tuple(min(255, int(c * 1.08)) for c in color)
355
- body_color = color
356
- window_color = (70, 110, 150)
357
- wheel_color = (35, 35, 35)
358
-
359
- wheel_width = width // 3
360
- wheel_height = 6
361
- for y in (body_rect.top + 4, body_rect.bottom - wheel_height - 4):
362
- left_wheel = pygame.Rect(2, y, wheel_width, wheel_height)
363
- right_wheel = pygame.Rect(width - wheel_width - 2, y, wheel_width, wheel_height)
364
- pygame.draw.rect(surface, wheel_color, left_wheel, border_radius=3)
365
- pygame.draw.rect(surface, wheel_color, right_wheel, border_radius=3)
366
-
367
- pygame.draw.rect(surface, body_color, body_rect, border_radius=4)
368
- pygame.draw.rect(surface, trim_color, body_rect, width=2, border_radius=4)
369
- pygame.draw.rect(surface, front_cap_color, front_cap, border_radius=10)
370
- pygame.draw.rect(surface, trim_color, front_cap, width=2, border_radius=10)
371
- pygame.draw.rect(surface, window_color, windshield_rect, border_radius=4)
372
-
373
- headlight_color = (245, 245, 200)
374
- for x in (front_cap.left + 5, front_cap.right - 5):
375
- pygame.draw.circle(surface, headlight_color, (x, body_rect.top + 5), 2)
376
- grille_rect = pygame.Rect(front_cap.centerx - 6, front_cap.top + 2, 12, 6)
377
- pygame.draw.rect(surface, trim_color, grille_rect, border_radius=2)
558
+ def _paint_car_surface_base(
559
+ surface: pygame.Surface,
560
+ *,
561
+ width: int,
562
+ height: int,
563
+ color: tuple[int, int, int],
564
+ ) -> None:
565
+ surface.fill((0, 0, 0, 0))
566
+
567
+ trim_color = tuple(int(c * 0.6) for c in color)
568
+ body_color = tuple(min(255, int(c * 1.15)) for c in color)
378
569
  tail_light_color = (255, 80, 50)
379
- for x in (body_rect.left + 5, body_rect.right - 5):
380
- pygame.draw.rect(
381
- surface,
382
- tail_light_color,
383
- (x - 2, body_rect.bottom - 5, 4, 3),
384
- border_radius=1,
570
+ headlight_color = (200, 200, 200)
571
+
572
+ base_width = 150.0
573
+ base_height = 210.0
574
+ scale_x = width / base_width
575
+ scale_y = height / base_height
576
+
577
+ def _rect(x: float, y: float, w: float, h: float) -> pygame.Rect:
578
+ return pygame.Rect(
579
+ int(round(x * scale_x)),
580
+ int(round(y * scale_y)),
581
+ max(1, int(round(w * scale_x))),
582
+ max(1, int(round(h * scale_y))),
583
+ )
584
+
585
+ def _radius(value: float) -> int:
586
+ return max(1, int(round(value * min(scale_x, scale_y))))
587
+
588
+ body_top = _rect(0, 0, 150, 140)
589
+ body_bottom = _rect(0, 70, 150, 140)
590
+ rear_bed = _rect(16, 98, 118, 88)
591
+
592
+ pygame.draw.rect(surface, trim_color, body_top, border_radius=_radius(50))
593
+ pygame.draw.rect(surface, trim_color, body_bottom, border_radius=_radius(37))
594
+ pygame.draw.rect(surface, body_color, rear_bed)
595
+
596
+ tail_left = _rect(30, 190, 30, 20)
597
+ tail_right = _rect(90, 190, 30, 20)
598
+ pygame.draw.rect(surface, tail_light_color, tail_left)
599
+ pygame.draw.rect(surface, tail_light_color, tail_right)
600
+
601
+ headlight_left = _rect(15, 7, 40, 20)
602
+ headlight_right = _rect(95, 7, 40, 20)
603
+ pygame.draw.ellipse(surface, headlight_color, headlight_left)
604
+ pygame.draw.ellipse(surface, headlight_color, headlight_right)
605
+
606
+
607
+ def build_car_directional_surfaces(
608
+ base_surface: pygame.Surface, *, bins: int = ANGLE_BINS
609
+ ) -> list[pygame.Surface]:
610
+ """Return pre-rotated car surfaces matching angle_bin_from_vector bins."""
611
+ surfaces: list[pygame.Surface] = []
612
+ upscale = _CAR_UPSCALE_FACTOR
613
+ if upscale > 1:
614
+ src_size = base_surface.get_size()
615
+ upscale_surface = pygame.transform.scale(
616
+ base_surface,
617
+ (src_size[0] * upscale, src_size[1] * upscale),
385
618
  )
619
+ else:
620
+ upscale_surface = base_surface
621
+ for idx in range(bins):
622
+ angle_rad = idx * ANGLE_STEP
623
+ rotation_deg = -math.degrees(angle_rad) - 90
624
+ rotated = pygame.transform.rotate(upscale_surface, rotation_deg)
625
+ if upscale > 1:
626
+ scaled = pygame.transform.smoothscale(
627
+ rotated,
628
+ (
629
+ max(1, rotated.get_width() // upscale),
630
+ max(1, rotated.get_height() // upscale),
631
+ ),
632
+ )
633
+ surfaces.append(scaled)
634
+ else:
635
+ surfaces.append(rotated)
636
+ return surfaces
386
637
 
387
638
 
388
639
  def paint_wall_surface(
@@ -472,6 +723,80 @@ def paint_wall_surface(
472
723
  _draw_face(surface)
473
724
 
474
725
 
726
+ def build_rubble_wall_surface(
727
+ size: int,
728
+ *,
729
+ fill_color: tuple[int, int, int],
730
+ border_color: tuple[int, int, int],
731
+ angle_deg: float,
732
+ offset_px: int | None = None,
733
+ scale_ratio: float = RUBBLE_SCALE_RATIO,
734
+ shadow_ratio: float = RUBBLE_SHADOW_RATIO,
735
+ bevel_depth: int = INTERNAL_WALL_BEVEL_DEPTH,
736
+ ) -> pygame.Surface:
737
+ offset_px = offset_px if offset_px is not None else rubble_offset_for_size(size)
738
+ safe_size = max(1, size)
739
+ base_size = max(1, int(round(safe_size * scale_ratio)))
740
+ tuned_bevel = min(bevel_depth, max(1, base_size // 2))
741
+ cache_key = (
742
+ safe_size,
743
+ fill_color,
744
+ border_color,
745
+ angle_deg,
746
+ offset_px,
747
+ scale_ratio,
748
+ shadow_ratio,
749
+ tuned_bevel,
750
+ )
751
+ cached = _RUBBLE_SURFACE_CACHE.get(cache_key)
752
+ if cached is not None:
753
+ return cached
754
+
755
+ top_surface = pygame.Surface((base_size, base_size), pygame.SRCALPHA)
756
+ paint_wall_surface(
757
+ top_surface,
758
+ fill_color=fill_color,
759
+ border_color=border_color,
760
+ bevel_depth=tuned_bevel,
761
+ bevel_mask=(False, False, False, False),
762
+ draw_bottom_side=False,
763
+ bottom_side_ratio=0.1,
764
+ side_shade_ratio=0.9,
765
+ )
766
+
767
+ shadow_fill = _scale_color(fill_color, ratio=shadow_ratio)
768
+ shadow_border = _scale_color(border_color, ratio=shadow_ratio)
769
+ shadow_surface = pygame.Surface((base_size, base_size), pygame.SRCALPHA)
770
+ paint_wall_surface(
771
+ shadow_surface,
772
+ fill_color=shadow_fill,
773
+ border_color=shadow_border,
774
+ bevel_depth=tuned_bevel,
775
+ bevel_mask=(False, False, False, False),
776
+ draw_bottom_side=False,
777
+ bottom_side_ratio=0.1,
778
+ side_shade_ratio=0.9,
779
+ )
780
+
781
+ if angle_deg:
782
+ top_surface = pygame.transform.rotate(top_surface, angle_deg)
783
+ shadow_surface = pygame.transform.rotate(shadow_surface, angle_deg)
784
+
785
+ final_surface = pygame.Surface((safe_size, safe_size), pygame.SRCALPHA)
786
+ center = final_surface.get_rect().center
787
+
788
+ shadow_rect = shadow_surface.get_rect(
789
+ center=(center[0] + offset_px, center[1] + offset_px)
790
+ )
791
+ final_surface.blit(shadow_surface, shadow_rect.topleft)
792
+
793
+ top_rect = top_surface.get_rect(center=center)
794
+ final_surface.blit(top_surface, top_rect.topleft)
795
+
796
+ _RUBBLE_SURFACE_CACHE[cache_key] = final_surface
797
+ return final_surface
798
+
799
+
475
800
  def paint_steel_beam_surface(
476
801
  surface: pygame.Surface,
477
802
  *,
@@ -522,43 +847,6 @@ def paint_steel_beam_surface(
522
847
  surface.blit(top_surface, top_rect.topleft)
523
848
 
524
849
 
525
- def paint_zombie_surface(
526
- surface: pygame.Surface,
527
- *,
528
- radius: int,
529
- palm_angle: float | None = None,
530
- tracker: bool = False,
531
- wall_follower: bool = False,
532
- ) -> None:
533
- if tracker:
534
- outline_color = TRACKER_OUTLINE_COLOR
535
- elif wall_follower:
536
- outline_color = WALL_FOLLOWER_OUTLINE_COLOR
537
- else:
538
- outline_color = DARK_RED
539
- surface.fill((0, 0, 0, 0))
540
- _draw_outlined_circle(
541
- surface,
542
- (radius, radius),
543
- radius,
544
- RED,
545
- outline_color,
546
- 1,
547
- )
548
- if palm_angle is None:
549
- return
550
- palm_radius = max(1, radius // 3)
551
- palm_offset = radius - palm_radius * 0.3
552
- palm_x = radius + math.cos(palm_angle) * palm_offset
553
- palm_y = radius + math.sin(palm_angle) * palm_offset
554
- pygame.draw.circle(
555
- surface,
556
- outline_color,
557
- (int(palm_x), int(palm_y)),
558
- palm_radius,
559
- )
560
-
561
-
562
850
  def build_fuel_can_surface(width: int, height: int) -> pygame.Surface:
563
851
  return _draw_polygon_surface(width, height, FUEL_CAN_SPEC)
564
852
 
@@ -572,6 +860,7 @@ def build_shoes_surface(width: int, height: int) -> pygame.Surface:
572
860
 
573
861
 
574
862
  __all__ = [
863
+ "angle_bin_from_vector",
575
864
  "EnvironmentPalette",
576
865
  "FogRing",
577
866
  "RenderAssets",
@@ -580,14 +869,20 @@ __all__ = [
580
869
  "resolve_car_color",
581
870
  "resolve_steel_beam_colors",
582
871
  "CAR_COLOR_SCHEMES",
583
- "build_player_surface",
584
- "build_survivor_surface",
585
- "build_zombie_surface",
872
+ "build_player_directional_surfaces",
873
+ "build_humanoid_directional_surfaces",
874
+ "draw_humanoid_hand",
875
+ "draw_humanoid_nose",
876
+ "build_survivor_directional_surfaces",
877
+ "build_zombie_directional_surfaces",
586
878
  "build_car_surface",
879
+ "build_car_directional_surfaces",
587
880
  "paint_car_surface",
588
881
  "paint_wall_surface",
882
+ "build_rubble_wall_surface",
883
+ "rubble_offset_for_size",
884
+ "RUBBLE_ROTATION_DEG",
589
885
  "paint_steel_beam_surface",
590
- "paint_zombie_surface",
591
886
  "build_fuel_can_surface",
592
887
  "build_flashlight_surface",
593
888
  "build_shoes_surface",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
5
6
  from dataclasses import dataclass
6
7
 
7
8
  from .entities_constants import FOV_RADIUS, PLAYER_RADIUS
@@ -11,9 +12,14 @@ HUMANOID_OUTLINE_COLOR = (0, 80, 200)
11
12
  HUMANOID_OUTLINE_WIDTH = 1
12
13
  BUDDY_COLOR = (0, 180, 63)
13
14
  SURVIVOR_COLOR = (198, 198, 198)
15
+ ZOMBIE_BODY_COLOR = (180, 0, 0)
16
+ ZOMBIE_OUTLINE_COLOR = (255, 60, 60)
17
+ ZOMBIE_NOSE_COLOR = (255, 80, 80)
14
18
  FALLING_ZOMBIE_COLOR = (45, 45, 45)
15
19
  FALLING_WHIRLWIND_COLOR = (200, 200, 200, 120)
16
20
  FALLING_DUST_COLOR = (70, 70, 70, 130)
21
+ ANGLE_BINS = 16
22
+ HAND_SPREAD_RAD = math.radians(75)
17
23
 
18
24
 
19
25
  @dataclass(frozen=True)
@@ -61,6 +67,15 @@ ENTITY_SHADOW_EDGE_SOFTNESS = 0.32
61
67
  PLAYER_SHADOW_RADIUS_MULT = 1.6
62
68
  PLAYER_SHADOW_ALPHA_MULT = 0.8
63
69
 
70
+ # --- Pitfall rendering ---
71
+ PITFALL_ABYSS_COLOR = (21, 20, 20)
72
+ PITFALL_SHADOW_RIM_COLOR = (38, 34, 34)
73
+ PITFALL_SHADOW_WIDTH = 6
74
+ PITFALL_EDGE_METAL_COLOR = (110, 110, 115)
75
+ PITFALL_EDGE_STRIPE_COLOR = (75, 75, 80)
76
+ PITFALL_EDGE_STRIPE_SPACING = 6
77
+ PITFALL_EDGE_DEPTH_OFFSET = 3
78
+
64
79
  FOG_RINGS = [
65
80
  FogRing(radius_factor=0.536, thickness=2),
66
81
  FogRing(radius_factor=0.645, thickness=3),
@@ -93,6 +108,11 @@ __all__ = [
93
108
  "FALLING_ZOMBIE_COLOR",
94
109
  "FALLING_WHIRLWIND_COLOR",
95
110
  "FALLING_DUST_COLOR",
111
+ "ZOMBIE_BODY_COLOR",
112
+ "ZOMBIE_OUTLINE_COLOR",
113
+ "ZOMBIE_NOSE_COLOR",
114
+ "ANGLE_BINS",
115
+ "HAND_SPREAD_RAD",
96
116
  "HUMANOID_OUTLINE_COLOR",
97
117
  "HUMANOID_OUTLINE_WIDTH",
98
118
  "SURVIVOR_COLOR",
@@ -110,6 +130,13 @@ __all__ = [
110
130
  "ENTITY_SHADOW_EDGE_SOFTNESS",
111
131
  "PLAYER_SHADOW_RADIUS_MULT",
112
132
  "PLAYER_SHADOW_ALPHA_MULT",
133
+ "PITFALL_ABYSS_COLOR",
134
+ "PITFALL_SHADOW_RIM_COLOR",
135
+ "PITFALL_SHADOW_WIDTH",
136
+ "PITFALL_EDGE_METAL_COLOR",
137
+ "PITFALL_EDGE_STRIPE_COLOR",
138
+ "PITFALL_EDGE_STRIPE_SPACING",
139
+ "PITFALL_EDGE_DEPTH_OFFSET",
113
140
  "FLASHLIGHT_HATCH_EXTRA_SCALE",
114
141
  "build_render_assets",
115
142
  ]