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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zombie_escape/render.py CHANGED
@@ -17,7 +17,17 @@ from .colors import (
17
17
  YELLOW,
18
18
  get_environment_palette,
19
19
  )
20
- from .entities import Camera, Car, Flashlight, FuelCan, Player, Survivor, Wall
20
+ from .entities import (
21
+ Camera,
22
+ Car,
23
+ Flashlight,
24
+ FuelCan,
25
+ Player,
26
+ SteelBeam,
27
+ Survivor,
28
+ Wall,
29
+ )
30
+ from .entities_constants import ZOMBIE_RADIUS
21
31
  from .font_utils import load_font
22
32
  from .gameplay_constants import (
23
33
  DEFAULT_FLASHLIGHT_SPAWN_COUNT,
@@ -25,8 +35,67 @@ from .gameplay_constants import (
25
35
  )
26
36
  from .localization import get_font_settings
27
37
  from .localization import translate as tr
28
- from .models import GameData, Stage
29
- from .render_assets import RenderAssets, resolve_wall_colors
38
+ from .models import DustRing, FallingZombie, Footprint, GameData, Stage
39
+ from .render_assets import RenderAssets, resolve_steel_beam_colors, resolve_wall_colors
40
+ from .render_constants import (
41
+ FALLING_DUST_COLOR,
42
+ FALLING_WHIRLWIND_COLOR,
43
+ FALLING_ZOMBIE_COLOR,
44
+ )
45
+
46
+ _SHADOW_TILE_CACHE: dict[tuple[int, int, float], surface.Surface] = {}
47
+ _SHADOW_LAYER_CACHE: dict[tuple[int, int], surface.Surface] = {}
48
+
49
+
50
+ def _get_shadow_tile_surface(
51
+ cell_size: int,
52
+ alpha: int,
53
+ *,
54
+ edge_softness: float = 0.35,
55
+ ) -> surface.Surface:
56
+ key = (max(1, cell_size), max(0, min(255, alpha)), edge_softness)
57
+ if key in _SHADOW_TILE_CACHE:
58
+ return _SHADOW_TILE_CACHE[key]
59
+ size = key[0]
60
+ surf = pygame.Surface((size, size), pygame.SRCALPHA)
61
+ base_alpha = key[1]
62
+ if edge_softness <= 0:
63
+ surf.fill((0, 0, 0, base_alpha))
64
+ _SHADOW_TILE_CACHE[key] = surf
65
+ return surf
66
+
67
+ softness = max(0.0, min(1.0, edge_softness))
68
+ fade_band = max(1, int(size * softness))
69
+ base_radius = max(1, int(size * 0.18))
70
+
71
+ def draw_layer(inset: int, layer_alpha: int) -> None:
72
+ rect_size = size - inset * 2
73
+ if rect_size <= 0:
74
+ return
75
+ radius = max(0, base_radius - inset)
76
+ pygame.draw.rect(
77
+ surf,
78
+ (0, 0, 0, layer_alpha),
79
+ pygame.Rect(inset, inset, rect_size, rect_size),
80
+ border_radius=radius,
81
+ )
82
+
83
+ outer_alpha = int(base_alpha * 0.45)
84
+ mid_alpha = int(base_alpha * 0.7)
85
+ draw_layer(0, outer_alpha)
86
+ draw_layer(max(1, fade_band // 2), mid_alpha)
87
+ draw_layer(fade_band, base_alpha)
88
+ _SHADOW_TILE_CACHE[key] = surf
89
+ return surf
90
+
91
+
92
+ def _get_shadow_layer(size: tuple[int, int]) -> surface.Surface:
93
+ key = (max(1, size[0]), max(1, size[1]))
94
+ if key in _SHADOW_LAYER_CACHE:
95
+ return _SHADOW_LAYER_CACHE[key]
96
+ layer = pygame.Surface(key, pygame.SRCALPHA)
97
+ _SHADOW_LAYER_CACHE[key] = layer
98
+ return layer
30
99
 
31
100
 
32
101
  def show_message(
@@ -61,7 +130,7 @@ def draw_level_overview(
61
130
  player: Player | None,
62
131
  car: Car | None,
63
132
  waiting_cars: list[Car] | None,
64
- footprints: list[dict[str, Any]],
133
+ footprints: list[Footprint],
65
134
  *,
66
135
  fuel: FuelCan | None = None,
67
136
  flashlights: list[Flashlight] | None = None,
@@ -71,32 +140,37 @@ def draw_level_overview(
71
140
  palette_key: str | None = None,
72
141
  ) -> None:
73
142
  palette = get_environment_palette(palette_key)
74
- base_floor = getattr(palette, "floor_primary", palette.outside)
143
+ base_floor = palette.floor_primary
75
144
  dark_floor = tuple(max(0, int(channel * 0.55)) for channel in base_floor)
76
145
  surface.fill(dark_floor)
77
146
  for wall in wall_group:
78
- if not isinstance(wall, Wall):
79
- raise TypeError("wall_group must contain Wall instances")
80
147
  if wall.max_health > 0:
81
148
  health_ratio = max(0.0, min(1.0, wall.health / wall.max_health))
82
149
  else:
83
150
  health_ratio = 0.0
84
- fill_color, _ = resolve_wall_colors(
85
- health_ratio=health_ratio,
86
- palette_category=wall.palette_category,
87
- palette=palette,
88
- )
89
- pygame.draw.rect(surface, fill_color, wall.rect)
151
+ if isinstance(wall, Wall):
152
+ fill_color, _ = resolve_wall_colors(
153
+ health_ratio=health_ratio,
154
+ palette_category=wall.palette_category,
155
+ palette=palette,
156
+ )
157
+ pygame.draw.rect(surface, fill_color, wall.rect)
158
+ elif isinstance(wall, SteelBeam):
159
+ fill_color, _ = resolve_steel_beam_colors(
160
+ health_ratio=health_ratio,
161
+ palette=palette,
162
+ )
163
+ pygame.draw.rect(surface, fill_color, wall.rect)
90
164
  now = pygame.time.get_ticks()
91
165
  for fp in footprints:
92
- age = now - fp["time"]
166
+ age = now - fp.time
93
167
  fade = 1 - (age / assets.footprint_lifetime_ms)
94
168
  fade = max(assets.footprint_min_fade, fade)
95
169
  color = tuple(int(c * fade) for c in FOOTPRINT_COLOR)
96
170
  pygame.draw.circle(
97
171
  surface,
98
172
  color,
99
- (int(fp["pos"][0]), int(fp["pos"][1])),
173
+ (int(fp.pos[0]), int(fp.pos[1])),
100
174
  assets.footprint_overview_radius,
101
175
  )
102
176
  if fuel and fuel.alive():
@@ -258,6 +332,9 @@ def _get_fog_overlay_surfaces(
258
332
  return overlays[key]
259
333
 
260
334
  scale = profile._scale(assets, stage)
335
+ ring_scale = scale
336
+ if profile.flashlight_count >= 2:
337
+ ring_scale += max(0.0, assets.flashlight_hatch_extra_scale)
261
338
  max_radius = int(assets.fov_radius * scale)
262
339
  padding = 32
263
340
  coverage_width = max(assets.screen_width * 2, max_radius * 2)
@@ -284,7 +361,7 @@ def _get_fog_overlay_surfaces(
284
361
  for y in range(0, height, p_h):
285
362
  for x in range(0, width, p_w):
286
363
  ring_surface.blit(pattern, (x, y))
287
- radius = int(assets.fov_radius * ring.radius_factor * scale)
364
+ radius = int(assets.fov_radius * ring.radius_factor * ring_scale)
288
365
  pygame.draw.circle(ring_surface, (0, 0, 0, 0), center, radius)
289
366
  ring_surfaces.append(ring_surface)
290
367
 
@@ -359,6 +436,96 @@ def _build_flashlight_fade_surface(
359
436
  return fade_surface
360
437
 
361
438
 
439
+ def _draw_fall_whirlwind(
440
+ screen: surface.Surface,
441
+ camera: Camera,
442
+ center: tuple[int, int],
443
+ progress: float,
444
+ ) -> None:
445
+ base_alpha = FALLING_WHIRLWIND_COLOR[3]
446
+ alpha = int(max(0, min(255, base_alpha * (1.0 - progress))))
447
+ if alpha <= 0:
448
+ return
449
+ color = (
450
+ FALLING_WHIRLWIND_COLOR[0],
451
+ FALLING_WHIRLWIND_COLOR[1],
452
+ FALLING_WHIRLWIND_COLOR[2],
453
+ alpha,
454
+ )
455
+ swirl_radius = max(2, int(ZOMBIE_RADIUS * 1.1))
456
+ offset = max(1, int(ZOMBIE_RADIUS * 0.6))
457
+ size = swirl_radius * 4
458
+ swirl = pygame.Surface((size, size), pygame.SRCALPHA)
459
+ cx = cy = size // 2
460
+ for idx in range(2):
461
+ angle = progress * math.tau * 0.3 + idx * (math.tau / 2)
462
+ ox = int(math.cos(angle) * offset)
463
+ oy = int(math.sin(angle) * offset)
464
+ pygame.draw.circle(swirl, color, (cx + ox, cy + oy), swirl_radius, width=2)
465
+ world_rect = pygame.Rect(0, 0, 1, 1)
466
+ world_rect.center = center
467
+ screen_center = camera.apply_rect(world_rect).center
468
+ screen.blit(swirl, swirl.get_rect(center=screen_center))
469
+
470
+
471
+ def _draw_falling_fx(
472
+ screen: surface.Surface,
473
+ camera: Camera,
474
+ falling_zombies: list[FallingZombie],
475
+ flashlight_count: int,
476
+ dust_rings: list[DustRing],
477
+ ) -> None:
478
+ if not falling_zombies and not dust_rings:
479
+ return
480
+ now = pygame.time.get_ticks()
481
+ for fall in falling_zombies:
482
+ pre_fx_ms = max(0, fall.pre_fx_ms)
483
+ fall_duration_ms = max(1, fall.fall_duration_ms)
484
+ fall_start = fall.started_at_ms + pre_fx_ms
485
+ impact_at = fall_start + fall_duration_ms
486
+ if now < fall_start:
487
+ if flashlight_count > 0 and pre_fx_ms > 0:
488
+ fx_progress = max(0.0, min(1.0, (now - fall.started_at_ms) / pre_fx_ms))
489
+ _draw_fall_whirlwind(screen, camera, fall.target_pos, fx_progress)
490
+ continue
491
+ if now >= impact_at:
492
+ continue
493
+ fall_progress = max(0.0, min(1.0, (now - fall_start) / fall_duration_ms))
494
+ eased = 1.0 - (1.0 - fall_progress) * (1.0 - fall_progress)
495
+ x = fall.target_pos[0]
496
+ y = int(fall.start_pos[1] + (fall.target_pos[1] - fall.start_pos[1]) * eased)
497
+ world_rect = pygame.Rect(0, 0, ZOMBIE_RADIUS * 2, ZOMBIE_RADIUS * 2)
498
+ world_rect.center = (x, y)
499
+ screen_rect = camera.apply_rect(world_rect)
500
+ pygame.draw.circle(
501
+ screen,
502
+ FALLING_ZOMBIE_COLOR,
503
+ screen_rect.center,
504
+ ZOMBIE_RADIUS,
505
+ )
506
+
507
+ for ring in list(dust_rings):
508
+ elapsed = now - ring.started_at_ms
509
+ if elapsed >= ring.duration_ms:
510
+ dust_rings.remove(ring)
511
+ continue
512
+ progress = max(0.0, min(1.0, elapsed / ring.duration_ms))
513
+ alpha = int(max(0, min(255, FALLING_DUST_COLOR[3] * (1.0 - progress))))
514
+ if alpha <= 0:
515
+ continue
516
+ radius = int(ZOMBIE_RADIUS * (0.7 + progress * 1.9))
517
+ color = (
518
+ FALLING_DUST_COLOR[0],
519
+ FALLING_DUST_COLOR[1],
520
+ FALLING_DUST_COLOR[2],
521
+ alpha,
522
+ )
523
+ world_rect = pygame.Rect(0, 0, 1, 1)
524
+ world_rect.center = ring.pos
525
+ screen_center = camera.apply_rect(world_rect).center
526
+ pygame.draw.circle(screen, color, screen_center, radius, width=2)
527
+
528
+
362
529
  def _draw_hint_arrow(
363
530
  screen: surface.Surface,
364
531
  camera: Camera,
@@ -411,6 +578,7 @@ def _draw_status_bar(
411
578
  seed: int | None = None,
412
579
  debug_mode: bool = False,
413
580
  zombie_group: sprite.Group | None = None,
581
+ falling_spawn_carry: int | None = None,
414
582
  ) -> None:
415
583
  """Render a compact status bar with current config flags and stage info."""
416
584
  bar_rect = pygame.Rect(
@@ -443,14 +611,15 @@ def _draw_status_bar(
443
611
  if steel_on:
444
612
  parts.append(tr("status.steel"))
445
613
  if debug_mode:
446
- parts.append(tr("status.debug"))
447
614
  if zombie_group is not None:
448
- zombies = [z for z in zombie_group if getattr(z, "alive", lambda: True)()]
615
+ zombies = [z for z in zombie_group if z.alive()]
449
616
  total = len(zombies)
450
- tracker = sum(1 for z in zombies if getattr(z, "tracker", False))
451
- wall = sum(1 for z in zombies if getattr(z, "wall_follower", False))
617
+ tracker = sum(1 for z in zombies if z.tracker)
618
+ wall = sum(1 for z in zombies if z.wall_follower)
452
619
  normal = max(0, total - tracker - wall)
453
620
  parts.append(f"Z:{total} N:{normal} T:{tracker} W:{wall}")
621
+ if falling_spawn_carry is not None:
622
+ parts.append(f"C:{max(0, falling_spawn_carry)}")
454
623
 
455
624
  status_text = " | ".join(parts)
456
625
  color = LIGHT_GRAY
@@ -472,45 +641,15 @@ def _draw_status_bar(
472
641
  print(f"Error rendering status bar: {e}")
473
642
 
474
643
 
475
- def draw(
476
- assets: RenderAssets,
644
+ def _draw_play_area(
477
645
  screen: surface.Surface,
478
- game_data: GameData,
479
- fov_target: pygame.sprite.Sprite | None,
480
- *,
481
- config: dict[str, Any],
482
- hint_target: tuple[int, int] | None = None,
483
- hint_color: tuple[int, int, int] | None = None,
484
- do_flip: bool = True,
485
- present_fn: Callable[[surface.Surface], None] | None = None,
486
- ) -> None:
487
- hint_color = hint_color or YELLOW
488
- state = game_data.state
489
- player = game_data.player
490
- if player is None:
491
- raise ValueError("draw requires an active player on game_data")
492
-
493
- camera = game_data.camera
494
- stage = game_data.stage
495
- outer_rect = game_data.layout.outer_rect
496
- outside_rects = game_data.layout.outside_rects or []
497
- all_sprites = game_data.groups.all_sprites
498
- fog_surfaces = game_data.fog
499
- footprints = state.footprints
500
- has_fuel = state.has_fuel
501
- flashlight_count = state.flashlight_count
502
- elapsed_play_ms = state.elapsed_play_ms
503
- fuel_message_until = state.fuel_message_until
504
- buddy_onboard = state.buddy_onboard
505
- buddy_required = stage.buddy_required_count if stage else 0
506
- survivors_onboard = state.survivors_onboard
507
- survivor_messages = list(state.survivor_messages)
508
- zombie_group = game_data.groups.zombie_group
509
- active_car = game_data.car if game_data.car and game_data.car.alive() else None
510
-
511
- palette = get_environment_palette(state.ambient_palette_key)
512
- screen.fill(palette.outside)
513
-
646
+ camera: Camera,
647
+ assets: RenderAssets,
648
+ palette: Any,
649
+ outer_rect: tuple[int, int, int, int],
650
+ outside_rects: list[pygame.Rect],
651
+ fall_spawn_cells: set[tuple[int, int]],
652
+ ) -> tuple[int, int, int, int, set[tuple[int, int]]]:
514
653
  xs, ys, xe, ye = outer_rect
515
654
  xs //= assets.internal_wall_grid_snap
516
655
  ys //= assets.internal_wall_grid_snap
@@ -556,38 +695,129 @@ def draw(
556
695
  for x in range(start_x, end_x):
557
696
  if (x, y) in outside_cells:
558
697
  continue
559
- if ((x // 2) + (y // 2)) % 2 == 0:
560
- lx, ly = (
561
- x * assets.internal_wall_grid_snap,
562
- y * assets.internal_wall_grid_snap,
563
- )
564
- r = pygame.Rect(
565
- lx,
566
- ly,
567
- assets.internal_wall_grid_snap,
568
- assets.internal_wall_grid_snap,
698
+ use_secondary = ((x // 2) + (y // 2)) % 2 == 0
699
+ if (x, y) in fall_spawn_cells:
700
+ color = (
701
+ palette.fall_zone_secondary
702
+ if use_secondary
703
+ else palette.fall_zone_primary
569
704
  )
570
- sr = camera.apply_rect(r)
571
- if sr.colliderect(screen.get_rect()):
572
- pygame.draw.rect(screen, palette.floor_secondary, sr)
573
-
574
- if config.get("footprints", {}).get("enabled", True):
575
- now = pygame.time.get_ticks()
576
- for fp in footprints:
577
- age = now - fp["time"]
578
- fade = 1 - (age / assets.footprint_lifetime_ms)
579
- fade = max(assets.footprint_min_fade, fade)
580
- color = tuple(int(c * fade) for c in FOOTPRINT_COLOR)
581
- fp_rect = pygame.Rect(
582
- fp["pos"][0] - assets.footprint_radius,
583
- fp["pos"][1] - assets.footprint_radius,
584
- assets.footprint_radius * 2,
585
- assets.footprint_radius * 2,
705
+ elif not use_secondary:
706
+ continue
707
+ else:
708
+ color = palette.floor_secondary
709
+ lx, ly = (
710
+ x * assets.internal_wall_grid_snap,
711
+ y * assets.internal_wall_grid_snap,
712
+ )
713
+ r = pygame.Rect(
714
+ lx,
715
+ ly,
716
+ assets.internal_wall_grid_snap,
717
+ assets.internal_wall_grid_snap,
586
718
  )
587
- sr = camera.apply_rect(fp_rect)
588
- if sr.colliderect(screen.get_rect().inflate(30, 30)):
589
- pygame.draw.circle(screen, color, sr.center, assets.footprint_radius)
719
+ sr = camera.apply_rect(r)
720
+ if sr.colliderect(screen.get_rect()):
721
+ pygame.draw.rect(screen, color, sr)
722
+
723
+ return xs, ys, xe, ye, outside_cells
724
+
725
+
726
+ def _draw_wall_shadows(
727
+ screen: surface.Surface,
728
+ camera: Camera,
729
+ *,
730
+ wall_cells: set[tuple[int, int]],
731
+ wall_group: sprite.Group | None,
732
+ outer_wall_cells: set[tuple[int, int]] | None,
733
+ cell_size: int,
734
+ light_source_pos: tuple[int, int] | None,
735
+ alpha: int = 54,
736
+ ) -> None:
737
+ if not wall_cells or cell_size <= 0 or light_source_pos is None:
738
+ return
739
+ inner_wall_cells = set(wall_cells)
740
+ if outer_wall_cells:
741
+ inner_wall_cells.difference_update(outer_wall_cells)
742
+ if wall_group and cell_size > 0:
743
+ for wall in wall_group:
744
+ if isinstance(wall, SteelBeam):
745
+ cell_x = int(wall.rect.centerx // cell_size)
746
+ cell_y = int(wall.rect.centery // cell_size)
747
+ inner_wall_cells.add((cell_x, cell_y))
748
+ if not inner_wall_cells:
749
+ return
750
+ base_shadow_size = max(cell_size + 2, int(cell_size * 1.35))
751
+ shadow_size = max(1, int(base_shadow_size * 1.5))
752
+ shadow_surface = _get_shadow_tile_surface(shadow_size, 255, edge_softness=0.12)
753
+ shadow_layer = _get_shadow_layer(screen.get_size())
754
+ shadow_layer.fill((0, 0, 0, 0))
755
+ screen_rect = screen.get_rect()
756
+ px, py = light_source_pos
757
+ offset = max(2, int(cell_size * 0.3 * (shadow_size / cell_size) * 1.2))
758
+ drew = False
759
+ for cell_x, cell_y in inner_wall_cells:
760
+ world_x = cell_x * cell_size
761
+ world_y = cell_y * cell_size
762
+ center_x = world_x + cell_size / 2
763
+ center_y = world_y + cell_size / 2
764
+ dx = center_x - px
765
+ dy = center_y - py
766
+ dist = math.hypot(dx, dy)
767
+ if dist <= 1e-3:
768
+ continue
769
+ nx = dx / dist
770
+ ny = dy / dist
771
+ shadow_rect = pygame.Rect(0, 0, shadow_size, shadow_size)
772
+ shadow_rect.center = (
773
+ int(center_x + nx * offset),
774
+ int(center_y + ny * offset),
775
+ )
776
+ shadow_screen_rect = camera.apply_rect(shadow_rect)
777
+ if not shadow_screen_rect.colliderect(screen_rect):
778
+ continue
779
+ shadow_layer.blit(shadow_surface, shadow_screen_rect.topleft)
780
+ drew = True
781
+ if drew:
782
+ shadow_layer.set_alpha(alpha)
783
+ screen.blit(shadow_layer, (0, 0))
784
+
785
+
786
+ def _draw_footprints(
787
+ screen: surface.Surface,
788
+ camera: Camera,
789
+ assets: RenderAssets,
790
+ footprints: list[Footprint],
791
+ *,
792
+ config: dict[str, Any],
793
+ ) -> None:
794
+ if not config.get("footprints", {}).get("enabled", True):
795
+ return
796
+ now = pygame.time.get_ticks()
797
+ for fp in footprints:
798
+ age = now - fp.time
799
+ fade = 1 - (age / assets.footprint_lifetime_ms)
800
+ fade = max(assets.footprint_min_fade, fade)
801
+ color = tuple(int(c * fade) for c in FOOTPRINT_COLOR)
802
+ fp_rect = pygame.Rect(
803
+ fp.pos[0] - assets.footprint_radius,
804
+ fp.pos[1] - assets.footprint_radius,
805
+ assets.footprint_radius * 2,
806
+ assets.footprint_radius * 2,
807
+ )
808
+ sr = camera.apply_rect(fp_rect)
809
+ if sr.colliderect(screen.get_rect().inflate(30, 30)):
810
+ pygame.draw.circle(screen, color, sr.center, assets.footprint_radius)
811
+
590
812
 
813
+ def _draw_entities(
814
+ screen: surface.Surface,
815
+ camera: Camera,
816
+ all_sprites: sprite.LayeredUpdates,
817
+ player: Player,
818
+ *,
819
+ has_fuel: bool,
820
+ ) -> pygame.Rect:
591
821
  screen_rect_inflated = screen.get_rect().inflate(100, 100)
592
822
  player_screen_rect: pygame.Rect | None = None
593
823
  for entity in all_sprites:
@@ -596,177 +826,273 @@ def draw(
596
826
  screen.blit(entity.image, sprite_screen_rect)
597
827
  if entity is player:
598
828
  player_screen_rect = sprite_screen_rect
829
+ _draw_fuel_indicator(
830
+ screen,
831
+ player_screen_rect,
832
+ has_fuel=has_fuel,
833
+ in_car=player.in_car,
834
+ )
835
+ return player_screen_rect or camera.apply_rect(player.rect)
599
836
 
600
- if player_screen_rect is None:
601
- player_screen_rect = camera.apply_rect(player.rect)
602
-
603
- if has_fuel and player_screen_rect and not player.in_car:
604
- indicator_size = 4
605
- padding = 1
606
- indicator_rect = pygame.Rect(
607
- player_screen_rect.right - indicator_size - padding,
608
- player_screen_rect.bottom - indicator_size - padding,
609
- indicator_size,
610
- indicator_size,
611
- )
612
- pygame.draw.rect(screen, YELLOW, indicator_rect)
613
- pygame.draw.rect(screen, (180, 160, 40), indicator_rect, width=1)
614
837
 
615
- if hint_target and player:
616
- current_fov_scale = _get_fog_scale(
617
- assets,
618
- stage,
619
- flashlight_count,
620
- )
621
- hint_ring_radius = assets.fov_radius * 0.5 * current_fov_scale
622
- _draw_hint_arrow(
623
- screen,
624
- camera,
625
- assets,
626
- player,
627
- hint_target,
628
- color=hint_color,
629
- ring_radius=hint_ring_radius,
630
- )
838
+ def _draw_fuel_indicator(
839
+ screen: surface.Surface,
840
+ player_screen_rect: pygame.Rect,
841
+ *,
842
+ has_fuel: bool,
843
+ in_car: bool,
844
+ ) -> None:
845
+ if not has_fuel or in_car:
846
+ return
847
+ indicator_size = 4
848
+ padding = 1
849
+ indicator_rect = pygame.Rect(
850
+ player_screen_rect.right - indicator_size - padding,
851
+ player_screen_rect.bottom - indicator_size - padding,
852
+ indicator_size,
853
+ indicator_size,
854
+ )
855
+ pygame.draw.rect(screen, YELLOW, indicator_rect)
856
+ pygame.draw.rect(screen, (180, 160, 40), indicator_rect, width=1)
631
857
 
632
- if fov_target is not None:
633
- fov_center_on_screen = list(camera.apply(fov_target).center)
634
- cam_rect = camera.camera
635
- horizontal_span = camera.width - assets.screen_width
636
- vertical_span = camera.height - assets.screen_height
637
- if horizontal_span <= 0 or (cam_rect.x != 0 and cam_rect.x != -horizontal_span):
638
- fov_center_on_screen[0] = assets.screen_width // 2
639
- if vertical_span <= 0 or (cam_rect.y != 0 and cam_rect.y != -vertical_span):
640
- fov_center_on_screen[1] = assets.screen_height // 2
641
- fov_center_tuple = (int(fov_center_on_screen[0]), int(fov_center_on_screen[1]))
642
- if state.dawn_ready:
643
- profile = FogProfile.DAWN
644
- else:
645
- profile = FogProfile._from_flashlight_count(flashlight_count)
646
- overlay = _get_fog_overlay_surfaces(
647
- fog_surfaces,
648
- assets,
649
- profile,
650
- stage=stage,
651
- )
652
- combined_surface: surface.Surface = overlay["combined"]
653
- screen.blit(
654
- combined_surface,
655
- combined_surface.get_rect(center=fov_center_tuple),
656
- )
657
858
 
658
- if not has_fuel and fuel_message_until > elapsed_play_ms:
659
- show_message(
660
- screen,
661
- tr("hud.need_fuel"),
662
- 18,
663
- ORANGE,
664
- (assets.screen_width // 2, assets.screen_height // 2),
665
- )
859
+ def _draw_hint_indicator(
860
+ screen: surface.Surface,
861
+ camera: Camera,
862
+ assets: RenderAssets,
863
+ player: Player,
864
+ hint_target: tuple[int, int] | None,
865
+ *,
866
+ hint_color: tuple[int, int, int],
867
+ stage: Stage | None,
868
+ flashlight_count: int,
869
+ ) -> None:
870
+ if not hint_target:
871
+ return
872
+ current_fov_scale = _get_fog_scale(
873
+ assets,
874
+ stage,
875
+ flashlight_count,
876
+ )
877
+ hint_ring_radius = assets.fov_radius * 0.5 * current_fov_scale
878
+ _draw_hint_arrow(
879
+ screen,
880
+ camera,
881
+ assets,
882
+ player,
883
+ hint_target,
884
+ color=hint_color,
885
+ ring_radius=hint_ring_radius,
886
+ )
666
887
 
667
- def _render_objective(lines: list[str]) -> None:
668
- try:
669
- font_settings = get_font_settings()
670
- font = load_font(font_settings.resource, font_settings.scaled_size(11))
671
- y = 8
672
- for line in lines:
673
- text_surface = font.render(line, False, YELLOW)
674
- text_rect = text_surface.get_rect(topleft=(12, y))
675
- screen.blit(text_surface, text_rect)
676
- y += text_rect.height + 4
677
- except pygame.error as e:
678
- print(f"Error rendering objective: {e}")
679
-
680
- def _render_survival_timer() -> None:
681
- if not (stage and stage.survival_stage):
682
- return
683
- goal_ms = state.survival_goal_ms
684
- if goal_ms <= 0:
685
- return
686
- elapsed_ms = max(0, min(goal_ms, state.survival_elapsed_ms))
687
- remaining_ms = max(0, goal_ms - elapsed_ms)
688
- padding = 12
689
- bar_height = 8
690
- y_pos = assets.screen_height - assets.status_bar_height - bar_height - 10
691
- bar_rect = pygame.Rect(
692
- padding,
693
- y_pos,
694
- assets.screen_width - padding * 2,
695
- bar_height,
696
- )
697
- track_surface = pygame.Surface(
698
- (bar_rect.width, bar_rect.height), pygame.SRCALPHA
888
+
889
+ def _draw_fog_of_war(
890
+ screen: surface.Surface,
891
+ camera: Camera,
892
+ assets: RenderAssets,
893
+ fog_surfaces: dict[str, Any],
894
+ fov_target: pygame.sprite.Sprite | None,
895
+ *,
896
+ stage: Stage | None,
897
+ flashlight_count: int,
898
+ dawn_ready: bool,
899
+ ) -> None:
900
+ if fov_target is None:
901
+ return
902
+ fov_center_on_screen = list(camera.apply(fov_target).center)
903
+ cam_rect = camera.camera
904
+ horizontal_span = camera.width - assets.screen_width
905
+ vertical_span = camera.height - assets.screen_height
906
+ if horizontal_span <= 0 or (cam_rect.x != 0 and cam_rect.x != -horizontal_span):
907
+ fov_center_on_screen[0] = assets.screen_width // 2
908
+ if vertical_span <= 0 or (cam_rect.y != 0 and cam_rect.y != -vertical_span):
909
+ fov_center_on_screen[1] = assets.screen_height // 2
910
+ fov_center_tuple = (int(fov_center_on_screen[0]), int(fov_center_on_screen[1]))
911
+ if dawn_ready:
912
+ profile = FogProfile.DAWN
913
+ else:
914
+ profile = FogProfile._from_flashlight_count(flashlight_count)
915
+ overlay = _get_fog_overlay_surfaces(
916
+ fog_surfaces,
917
+ assets,
918
+ profile,
919
+ stage=stage,
920
+ )
921
+ combined_surface: surface.Surface = overlay["combined"]
922
+ screen.blit(
923
+ combined_surface,
924
+ combined_surface.get_rect(center=fov_center_tuple),
925
+ )
926
+
927
+
928
+ def _draw_need_fuel_message(
929
+ screen: surface.Surface,
930
+ assets: RenderAssets,
931
+ *,
932
+ has_fuel: bool,
933
+ fuel_message_until: int,
934
+ elapsed_play_ms: int,
935
+ ) -> None:
936
+ if has_fuel or fuel_message_until <= elapsed_play_ms:
937
+ return
938
+ show_message(
939
+ screen,
940
+ tr("hud.need_fuel"),
941
+ 18,
942
+ ORANGE,
943
+ (assets.screen_width // 2, assets.screen_height // 2),
944
+ )
945
+
946
+
947
+ def _draw_objective(lines: list[str], *, screen: surface.Surface) -> None:
948
+ try:
949
+ font_settings = get_font_settings()
950
+ font = load_font(font_settings.resource, font_settings.scaled_size(11))
951
+ y = 8
952
+ for line in lines:
953
+ text_surface = font.render(line, False, YELLOW)
954
+ text_rect = text_surface.get_rect(topleft=(12, y))
955
+ screen.blit(text_surface, text_rect)
956
+ y += text_rect.height + 4
957
+ except pygame.error as e:
958
+ print(f"Error rendering objective: {e}")
959
+
960
+
961
+ def _draw_endurance_timer(
962
+ screen: surface.Surface,
963
+ assets: RenderAssets,
964
+ *,
965
+ stage: Stage | None,
966
+ state: Any,
967
+ ) -> None:
968
+ if not (stage and stage.endurance_stage):
969
+ return
970
+ goal_ms = state.endurance_goal_ms
971
+ if goal_ms <= 0:
972
+ return
973
+ elapsed_ms = max(0, min(goal_ms, state.endurance_elapsed_ms))
974
+ remaining_ms = max(0, goal_ms - elapsed_ms)
975
+ padding = 12
976
+ bar_height = 8
977
+ y_pos = assets.screen_height - assets.status_bar_height - bar_height - 10
978
+ bar_rect = pygame.Rect(
979
+ padding,
980
+ y_pos,
981
+ assets.screen_width - padding * 2,
982
+ bar_height,
983
+ )
984
+ track_surface = pygame.Surface((bar_rect.width, bar_rect.height), pygame.SRCALPHA)
985
+ track_surface.fill((0, 0, 0, 140))
986
+ screen.blit(track_surface, bar_rect.topleft)
987
+ progress_ratio = elapsed_ms / goal_ms if goal_ms else 0.0
988
+ progress_width = int(bar_rect.width * max(0.0, min(1.0, progress_ratio)))
989
+ if progress_width > 0:
990
+ fill_color = (120, 20, 20)
991
+ if state.dawn_ready:
992
+ fill_color = (25, 40, 120)
993
+ fill_rect = pygame.Rect(
994
+ bar_rect.left,
995
+ bar_rect.top,
996
+ progress_width,
997
+ bar_rect.height,
699
998
  )
700
- track_surface.fill((0, 0, 0, 140))
701
- screen.blit(track_surface, bar_rect.topleft)
702
- progress_ratio = elapsed_ms / goal_ms if goal_ms else 0.0
703
- progress_width = int(bar_rect.width * max(0.0, min(1.0, progress_ratio)))
704
- if progress_width > 0:
705
- fill_color = (120, 20, 20)
706
- if state.dawn_ready:
707
- fill_color = (25, 40, 120)
708
- fill_rect = pygame.Rect(
709
- bar_rect.left,
710
- bar_rect.top,
711
- progress_width,
712
- bar_rect.height,
999
+ pygame.draw.rect(screen, fill_color, fill_rect)
1000
+ display_ms = int(remaining_ms * SURVIVAL_FAKE_CLOCK_RATIO)
1001
+ display_ms = max(0, display_ms)
1002
+ display_hours = display_ms // 3_600_000
1003
+ display_minutes = (display_ms % 3_600_000) // 60_000
1004
+ display_label = f"{int(display_hours):02d}:{int(display_minutes):02d}"
1005
+ timer_text = tr("hud.endurance_timer_label", time=display_label)
1006
+ try:
1007
+ font_settings = get_font_settings()
1008
+ font = load_font(font_settings.resource, font_settings.scaled_size(12))
1009
+ text_surface = font.render(timer_text, False, LIGHT_GRAY)
1010
+ text_rect = text_surface.get_rect(left=bar_rect.left, bottom=bar_rect.top - 2)
1011
+ screen.blit(text_surface, text_rect)
1012
+ if state.time_accel_active:
1013
+ accel_text = tr("hud.time_accel")
1014
+ accel_surface = font.render(accel_text, False, YELLOW)
1015
+ accel_rect = accel_surface.get_rect(
1016
+ right=bar_rect.right, bottom=bar_rect.top - 2
713
1017
  )
714
- pygame.draw.rect(screen, fill_color, fill_rect)
715
- display_ms = int(remaining_ms * SURVIVAL_FAKE_CLOCK_RATIO)
716
- display_ms = max(0, display_ms)
717
- display_hours = display_ms // 3_600_000
718
- display_minutes = (display_ms % 3_600_000) // 60_000
719
- display_label = f"{int(display_hours):02d}:{int(display_minutes):02d}"
720
- timer_text = tr("hud.survival_timer_label", time=display_label)
721
- try:
722
- font_settings = get_font_settings()
723
- font = load_font(font_settings.resource, font_settings.scaled_size(12))
724
- text_surface = font.render(timer_text, False, LIGHT_GRAY)
725
- text_rect = text_surface.get_rect(
726
- left=bar_rect.left, bottom=bar_rect.top - 2
1018
+ screen.blit(accel_surface, accel_rect)
1019
+ else:
1020
+ hint_text = tr("hud.time_accel_hint")
1021
+ hint_surface = font.render(hint_text, False, LIGHT_GRAY)
1022
+ hint_rect = hint_surface.get_rect(
1023
+ right=bar_rect.right, bottom=bar_rect.top - 2
727
1024
  )
728
- screen.blit(text_surface, text_rect)
729
- if state.time_accel_active:
730
- accel_text = tr("hud.time_accel")
731
- accel_surface = font.render(accel_text, False, YELLOW)
732
- accel_rect = accel_surface.get_rect(
733
- right=bar_rect.right, bottom=bar_rect.top - 2
734
- )
735
- screen.blit(accel_surface, accel_rect)
736
- else:
737
- hint_text = tr("hud.time_accel_hint")
738
- hint_surface = font.render(hint_text, False, LIGHT_GRAY)
739
- hint_rect = hint_surface.get_rect(
740
- right=bar_rect.right, bottom=bar_rect.top - 2
741
- )
742
- screen.blit(hint_surface, hint_rect)
743
- except pygame.error as e:
744
- print(f"Error rendering survival timer: {e}")
1025
+ screen.blit(hint_surface, hint_rect)
1026
+ except pygame.error as e:
1027
+ print(f"Error rendering endurance timer: {e}")
745
1028
 
746
- def _render_time_accel_indicator() -> None:
747
- if stage and stage.survival_stage:
748
- return
749
- try:
750
- font_settings = get_font_settings()
751
- font = load_font(font_settings.resource, font_settings.scaled_size(12))
752
- if state.time_accel_active:
753
- text = tr("hud.time_accel")
754
- color = YELLOW
755
- else:
756
- text = tr("hud.time_accel_hint")
757
- color = LIGHT_GRAY
758
- text_surface = font.render(text, False, color)
759
- bottom_margin = assets.status_bar_height + 6
760
- text_rect = text_surface.get_rect(
761
- right=assets.screen_width - 12,
762
- bottom=assets.screen_height - bottom_margin,
1029
+
1030
+ def _draw_time_accel_indicator(
1031
+ screen: surface.Surface,
1032
+ assets: RenderAssets,
1033
+ *,
1034
+ stage: Stage | None,
1035
+ state: Any,
1036
+ ) -> None:
1037
+ if stage and stage.endurance_stage:
1038
+ return
1039
+ try:
1040
+ font_settings = get_font_settings()
1041
+ font = load_font(font_settings.resource, font_settings.scaled_size(12))
1042
+ if state.time_accel_active:
1043
+ text = tr("hud.time_accel")
1044
+ color = YELLOW
1045
+ else:
1046
+ text = tr("hud.time_accel_hint")
1047
+ color = LIGHT_GRAY
1048
+ text_surface = font.render(text, False, color)
1049
+ bottom_margin = assets.status_bar_height + 6
1050
+ text_rect = text_surface.get_rect(
1051
+ right=assets.screen_width - 12,
1052
+ bottom=assets.screen_height - bottom_margin,
1053
+ )
1054
+ screen.blit(text_surface, text_rect)
1055
+ except pygame.error as e:
1056
+ print(f"Error rendering acceleration indicator: {e}")
1057
+
1058
+
1059
+ def _draw_survivor_messages(
1060
+ screen: surface.Surface,
1061
+ assets: RenderAssets,
1062
+ survivor_messages: list[dict[str, Any]],
1063
+ ) -> None:
1064
+ if not survivor_messages:
1065
+ return
1066
+ try:
1067
+ font_settings = get_font_settings()
1068
+ font = load_font(font_settings.resource, font_settings.scaled_size(14))
1069
+ base_y = assets.screen_height // 2 - 70
1070
+ for idx, message in enumerate(survivor_messages[:3]):
1071
+ text = message.get("text", "")
1072
+ if not text:
1073
+ continue
1074
+ msg_surface = font.render(text, False, ORANGE)
1075
+ msg_rect = msg_surface.get_rect(
1076
+ center=(assets.screen_width // 2, base_y + idx * 18)
763
1077
  )
764
- screen.blit(text_surface, text_rect)
765
- except pygame.error as e:
766
- print(f"Error rendering acceleration indicator: {e}")
1078
+ screen.blit(msg_surface, msg_rect)
1079
+ except pygame.error as e:
1080
+ print(f"Error rendering survivor message: {e}")
767
1081
 
1082
+
1083
+ def _build_objective_lines(
1084
+ *,
1085
+ stage: Stage | None,
1086
+ state: Any,
1087
+ player: Player,
1088
+ active_car: Car | None,
1089
+ has_fuel: bool,
1090
+ buddy_onboard: int,
1091
+ buddy_required: int,
1092
+ survivors_onboard: int,
1093
+ ) -> list[str]:
768
1094
  objective_lines: list[str] = []
769
- if stage and stage.survival_stage:
1095
+ if stage and stage.endurance_stage:
770
1096
  if state.dawn_ready:
771
1097
  objective_lines.append(tr("objectives.get_outside"))
772
1098
  else:
@@ -807,29 +1133,139 @@ def draw(
807
1133
  objective_lines.append(
808
1134
  tr("objectives.survivors_onboard", count=survivors_onboard, limit=limit)
809
1135
  )
1136
+ return objective_lines
810
1137
 
811
- if objective_lines:
812
- _render_objective(objective_lines)
813
- if survivor_messages:
814
- try:
815
- font_settings = get_font_settings()
816
- font = load_font(font_settings.resource, font_settings.scaled_size(14))
817
- base_y = assets.screen_height // 2 - 70
818
- for idx, message in enumerate(survivor_messages[:3]):
819
- text = message.get("text", "")
820
- if not text:
821
- continue
822
- msg_surface = font.render(text, False, ORANGE)
823
- msg_rect = msg_surface.get_rect(
824
- center=(assets.screen_width // 2, base_y + idx * 18)
825
- )
826
- screen.blit(msg_surface, msg_rect)
827
- except pygame.error as e:
828
- print(f"Error rendering survivor message: {e}")
829
- if stage and stage.survival_stage:
830
- _render_survival_timer()
1138
+
1139
+ def draw(
1140
+ assets: RenderAssets,
1141
+ screen: surface.Surface,
1142
+ game_data: GameData,
1143
+ *,
1144
+ config: dict[str, Any],
1145
+ hint_target: tuple[int, int] | None = None,
1146
+ hint_color: tuple[int, int, int] | None = None,
1147
+ do_flip: bool = True,
1148
+ present_fn: Callable[[surface.Surface], None] | None = None,
1149
+ ) -> None:
1150
+ hint_color = hint_color or YELLOW
1151
+ state = game_data.state
1152
+ player = game_data.player
1153
+ if player is None:
1154
+ raise ValueError("draw requires an active player on game_data")
1155
+
1156
+ camera = game_data.camera
1157
+ stage = game_data.stage
1158
+ outer_rect = game_data.layout.outer_rect
1159
+ outside_rects = game_data.layout.outside_rects or []
1160
+ all_sprites = game_data.groups.all_sprites
1161
+ fog_surfaces = game_data.fog
1162
+ footprints = state.footprints
1163
+ has_fuel = state.has_fuel
1164
+ flashlight_count = state.flashlight_count
1165
+ elapsed_play_ms = state.elapsed_play_ms
1166
+ fuel_message_until = state.fuel_message_until
1167
+ buddy_onboard = state.buddy_onboard
1168
+ buddy_required = stage.buddy_required_count if stage else 0
1169
+ survivors_onboard = state.survivors_onboard
1170
+ survivor_messages = list(state.survivor_messages)
1171
+ zombie_group = game_data.groups.zombie_group
1172
+ active_car = game_data.car if game_data.car and game_data.car.alive() else None
1173
+ if player.in_car and game_data.car and game_data.car.alive():
1174
+ fov_target = game_data.car
831
1175
  else:
832
- _render_time_accel_indicator()
1176
+ fov_target = player
1177
+
1178
+ palette = get_environment_palette(state.ambient_palette_key)
1179
+ screen.fill(palette.outside)
1180
+
1181
+ _draw_play_area(
1182
+ screen,
1183
+ camera,
1184
+ assets,
1185
+ palette,
1186
+ outer_rect,
1187
+ outside_rects,
1188
+ game_data.layout.fall_spawn_cells,
1189
+ )
1190
+ _draw_wall_shadows(
1191
+ screen,
1192
+ camera,
1193
+ wall_cells=game_data.layout.wall_cells,
1194
+ wall_group=game_data.groups.wall_group,
1195
+ outer_wall_cells=game_data.layout.outer_wall_cells,
1196
+ cell_size=game_data.cell_size,
1197
+ light_source_pos=(
1198
+ None if (stage and stage.endurance_stage and state.dawn_ready) else fov_target.rect.center
1199
+ )
1200
+ if fov_target
1201
+ else None,
1202
+ )
1203
+ _draw_footprints(
1204
+ screen,
1205
+ camera,
1206
+ assets,
1207
+ footprints,
1208
+ config=config,
1209
+ )
1210
+ _draw_entities(
1211
+ screen,
1212
+ camera,
1213
+ all_sprites,
1214
+ player,
1215
+ has_fuel=has_fuel,
1216
+ )
1217
+
1218
+ _draw_falling_fx(
1219
+ screen,
1220
+ camera,
1221
+ state.falling_zombies,
1222
+ state.flashlight_count,
1223
+ state.dust_rings,
1224
+ )
1225
+
1226
+ _draw_hint_indicator(
1227
+ screen,
1228
+ camera,
1229
+ assets,
1230
+ player,
1231
+ hint_target,
1232
+ hint_color=hint_color,
1233
+ stage=stage,
1234
+ flashlight_count=flashlight_count,
1235
+ )
1236
+ _draw_fog_of_war(
1237
+ screen,
1238
+ camera,
1239
+ assets,
1240
+ fog_surfaces,
1241
+ fov_target,
1242
+ stage=stage,
1243
+ flashlight_count=flashlight_count,
1244
+ dawn_ready=state.dawn_ready,
1245
+ )
1246
+ _draw_need_fuel_message(
1247
+ screen,
1248
+ assets,
1249
+ has_fuel=has_fuel,
1250
+ fuel_message_until=fuel_message_until,
1251
+ elapsed_play_ms=elapsed_play_ms,
1252
+ )
1253
+
1254
+ objective_lines = _build_objective_lines(
1255
+ stage=stage,
1256
+ state=state,
1257
+ player=player,
1258
+ active_car=active_car,
1259
+ has_fuel=has_fuel,
1260
+ buddy_onboard=buddy_onboard,
1261
+ buddy_required=buddy_required,
1262
+ survivors_onboard=survivors_onboard,
1263
+ )
1264
+ if objective_lines:
1265
+ _draw_objective(objective_lines, screen=screen)
1266
+ _draw_survivor_messages(screen, assets, survivor_messages)
1267
+ _draw_endurance_timer(screen, assets, stage=stage, state=state)
1268
+ _draw_time_accel_indicator(screen, assets, stage=stage, state=state)
833
1269
  _draw_status_bar(
834
1270
  screen,
835
1271
  assets,
@@ -838,6 +1274,7 @@ def draw(
838
1274
  seed=state.seed,
839
1275
  debug_mode=state.debug_mode,
840
1276
  zombie_group=zombie_group,
1277
+ falling_spawn_carry=state.falling_spawn_carry,
841
1278
  )
842
1279
  if do_flip:
843
1280
  if present_fn: