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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zombie_escape/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,82 @@ 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
+ SHADOW_MIN_RATIO,
45
+ SHADOW_OVERSAMPLE,
46
+ SHADOW_RADIUS_RATIO,
47
+ SHADOW_STEPS,
48
+ )
49
+
50
+ _SHADOW_TILE_CACHE: dict[tuple[int, int, float], surface.Surface] = {}
51
+ _SHADOW_LAYER_CACHE: dict[tuple[int, int], surface.Surface] = {}
52
+
53
+
54
+ def _get_shadow_tile_surface(
55
+ cell_size: int,
56
+ alpha: int,
57
+ *,
58
+ edge_softness: float = 0.35,
59
+ ) -> surface.Surface:
60
+ key = (max(1, cell_size), max(0, min(255, alpha)), edge_softness)
61
+ if key in _SHADOW_TILE_CACHE:
62
+ return _SHADOW_TILE_CACHE[key]
63
+ size = key[0]
64
+ oversample = SHADOW_OVERSAMPLE
65
+ render_size = size * oversample
66
+ render_surf = pygame.Surface((render_size, render_size), pygame.SRCALPHA)
67
+ base_alpha = key[1]
68
+ if edge_softness <= 0:
69
+ render_surf.fill((0, 0, 0, base_alpha))
70
+ if oversample > 1:
71
+ surf = pygame.transform.smoothscale(render_surf, (size, size))
72
+ else:
73
+ surf = render_surf
74
+ _SHADOW_TILE_CACHE[key] = surf
75
+ return surf
76
+
77
+ softness = max(0.0, min(1.0, edge_softness))
78
+ fade_band = max(1, int(render_size * softness))
79
+ base_radius = max(1, int(render_size * SHADOW_RADIUS_RATIO))
80
+
81
+ render_surf.fill((0, 0, 0, 0))
82
+ steps = SHADOW_STEPS
83
+ min_ratio = SHADOW_MIN_RATIO
84
+ for idx in range(steps):
85
+ t = idx / (steps - 1) if steps > 1 else 1.0
86
+ inset = int(fade_band * t)
87
+ rect_size = render_size - inset * 2
88
+ if rect_size <= 0:
89
+ continue
90
+ radius = max(0, base_radius - inset)
91
+ layer_alpha = int(base_alpha * (min_ratio + (1.0 - min_ratio) * t))
92
+ pygame.draw.rect(
93
+ render_surf,
94
+ (0, 0, 0, layer_alpha),
95
+ pygame.Rect(inset, inset, rect_size, rect_size),
96
+ border_radius=radius,
97
+ )
98
+
99
+ if oversample > 1:
100
+ surf = pygame.transform.smoothscale(render_surf, (size, size))
101
+ else:
102
+ surf = render_surf
103
+ _SHADOW_TILE_CACHE[key] = surf
104
+ return surf
105
+
106
+
107
+ def _get_shadow_layer(size: tuple[int, int]) -> surface.Surface:
108
+ key = (max(1, size[0]), max(1, size[1]))
109
+ if key in _SHADOW_LAYER_CACHE:
110
+ return _SHADOW_LAYER_CACHE[key]
111
+ layer = pygame.Surface(key, pygame.SRCALPHA)
112
+ _SHADOW_LAYER_CACHE[key] = layer
113
+ return layer
30
114
 
31
115
 
32
116
  def show_message(
@@ -61,7 +145,7 @@ def draw_level_overview(
61
145
  player: Player | None,
62
146
  car: Car | None,
63
147
  waiting_cars: list[Car] | None,
64
- footprints: list[dict[str, Any]],
148
+ footprints: list[Footprint],
65
149
  *,
66
150
  fuel: FuelCan | None = None,
67
151
  flashlights: list[Flashlight] | None = None,
@@ -71,32 +155,37 @@ def draw_level_overview(
71
155
  palette_key: str | None = None,
72
156
  ) -> None:
73
157
  palette = get_environment_palette(palette_key)
74
- base_floor = getattr(palette, "floor_primary", palette.outside)
158
+ base_floor = palette.floor_primary
75
159
  dark_floor = tuple(max(0, int(channel * 0.55)) for channel in base_floor)
76
160
  surface.fill(dark_floor)
77
161
  for wall in wall_group:
78
- if not isinstance(wall, Wall):
79
- raise TypeError("wall_group must contain Wall instances")
80
162
  if wall.max_health > 0:
81
163
  health_ratio = max(0.0, min(1.0, wall.health / wall.max_health))
82
164
  else:
83
165
  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)
166
+ if isinstance(wall, Wall):
167
+ fill_color, _ = resolve_wall_colors(
168
+ health_ratio=health_ratio,
169
+ palette_category=wall.palette_category,
170
+ palette=palette,
171
+ )
172
+ pygame.draw.rect(surface, fill_color, wall.rect)
173
+ elif isinstance(wall, SteelBeam):
174
+ fill_color, _ = resolve_steel_beam_colors(
175
+ health_ratio=health_ratio,
176
+ palette=palette,
177
+ )
178
+ pygame.draw.rect(surface, fill_color, wall.rect)
90
179
  now = pygame.time.get_ticks()
91
180
  for fp in footprints:
92
- age = now - fp["time"]
181
+ age = now - fp.time
93
182
  fade = 1 - (age / assets.footprint_lifetime_ms)
94
183
  fade = max(assets.footprint_min_fade, fade)
95
184
  color = tuple(int(c * fade) for c in FOOTPRINT_COLOR)
96
185
  pygame.draw.circle(
97
186
  surface,
98
187
  color,
99
- (int(fp["pos"][0]), int(fp["pos"][1])),
188
+ (int(fp.pos[0]), int(fp.pos[1])),
100
189
  assets.footprint_overview_radius,
101
190
  )
102
191
  if fuel and fuel.alive():
@@ -206,19 +295,21 @@ def _get_hatch_pattern(
206
295
  fog_data: dict[str, Any],
207
296
  thickness: int,
208
297
  *,
209
- pixel_scale: int = 1,
210
298
  color: tuple[int, int, int, int] | None = None,
211
299
  ) -> surface.Surface:
212
- """Return cached ordered-dither tile surface (Bayer-style, optionally chunky)."""
300
+ """Return cached dot hatch tile surface (Bayer-ordered, optionally chunky)."""
213
301
  cache = fog_data.setdefault("hatch_patterns", {})
214
- pixel_scale = max(1, pixel_scale)
215
- key = (thickness, pixel_scale, color)
302
+ key = (thickness, color)
216
303
  if key in cache:
217
304
  return cache[key]
218
305
 
219
- spacing = 20
306
+ spacing = 4
307
+ oversample = 3
220
308
  density = max(1, min(thickness, 16))
221
- pattern = pygame.Surface((spacing, spacing), pygame.SRCALPHA)
309
+ pattern_size = spacing * 8
310
+ hi_spacing = spacing * oversample
311
+ hi_pattern_size = pattern_size * oversample
312
+ pattern = pygame.Surface((hi_pattern_size, hi_pattern_size), pygame.SRCALPHA)
222
313
 
223
314
  # 8x8 Bayer matrix values 0..63 for ordered dithering
224
315
  bayer = [
@@ -232,14 +323,20 @@ def _get_hatch_pattern(
232
323
  [63, 31, 55, 23, 61, 29, 53, 21],
233
324
  ]
234
325
  threshold = int((density / 16) * 64)
235
- for y in range(spacing):
236
- for x in range(spacing):
237
- if bayer[y % 8][x % 8] < threshold:
238
- pattern.set_at((x, y), color or (0, 0, 0, 255))
326
+ dot_radius = max(
327
+ 1,
328
+ min(hi_spacing, int(math.ceil((density / 16) * hi_spacing))),
329
+ )
330
+ dot_color = color or (0, 0, 0, 255)
331
+ for grid_y in range(8):
332
+ for grid_x in range(8):
333
+ if bayer[grid_y][grid_x] < threshold:
334
+ cx = grid_x * hi_spacing + hi_spacing // 2
335
+ cy = grid_y * hi_spacing + hi_spacing // 2
336
+ pygame.draw.circle(pattern, dot_color, (cx, cy), dot_radius)
239
337
 
240
- if pixel_scale > 1:
241
- scaled_size = (spacing * pixel_scale, spacing * pixel_scale)
242
- pattern = pygame.transform.scale(pattern, scaled_size)
338
+ if oversample > 1:
339
+ pattern = pygame.transform.smoothscale(pattern, (pattern_size, pattern_size))
243
340
 
244
341
  cache[key] = pattern
245
342
  return pattern
@@ -258,6 +355,9 @@ def _get_fog_overlay_surfaces(
258
355
  return overlays[key]
259
356
 
260
357
  scale = profile._scale(assets, stage)
358
+ ring_scale = scale
359
+ if profile.flashlight_count >= 2:
360
+ ring_scale += max(0.0, assets.flashlight_hatch_extra_scale)
261
361
  max_radius = int(assets.fov_radius * scale)
262
362
  padding = 32
263
363
  coverage_width = max(assets.screen_width * 2, max_radius * 2)
@@ -277,14 +377,13 @@ def _get_fog_overlay_surfaces(
277
377
  pattern = _get_hatch_pattern(
278
378
  fog_data,
279
379
  ring.thickness,
280
- pixel_scale=assets.fog_hatch_pixel_scale,
281
380
  color=base_color,
282
381
  )
283
382
  p_w, p_h = pattern.get_size()
284
383
  for y in range(0, height, p_h):
285
384
  for x in range(0, width, p_w):
286
385
  ring_surface.blit(pattern, (x, y))
287
- radius = int(assets.fov_radius * ring.radius_factor * scale)
386
+ radius = int(assets.fov_radius * ring.radius_factor * ring_scale)
288
387
  pygame.draw.circle(ring_surface, (0, 0, 0, 0), center, radius)
289
388
  ring_surfaces.append(ring_surface)
290
389
 
@@ -313,7 +412,7 @@ def _build_flashlight_fade_surface(
313
412
  *,
314
413
  start_ratio: float = 0.2,
315
414
  max_alpha: int = 220,
316
- outer_extension: int = 50,
415
+ outer_extension: int = 30,
317
416
  ) -> surface.Surface:
318
417
  """Return a radial gradient so flashlight edges softly darken again."""
319
418
 
@@ -359,6 +458,96 @@ def _build_flashlight_fade_surface(
359
458
  return fade_surface
360
459
 
361
460
 
461
+ def _draw_fall_whirlwind(
462
+ screen: surface.Surface,
463
+ camera: Camera,
464
+ center: tuple[int, int],
465
+ progress: float,
466
+ ) -> None:
467
+ base_alpha = FALLING_WHIRLWIND_COLOR[3]
468
+ alpha = int(max(0, min(255, base_alpha * (1.0 - progress))))
469
+ if alpha <= 0:
470
+ return
471
+ color = (
472
+ FALLING_WHIRLWIND_COLOR[0],
473
+ FALLING_WHIRLWIND_COLOR[1],
474
+ FALLING_WHIRLWIND_COLOR[2],
475
+ alpha,
476
+ )
477
+ swirl_radius = max(2, int(ZOMBIE_RADIUS * 1.1))
478
+ offset = max(1, int(ZOMBIE_RADIUS * 0.6))
479
+ size = swirl_radius * 4
480
+ swirl = pygame.Surface((size, size), pygame.SRCALPHA)
481
+ cx = cy = size // 2
482
+ for idx in range(2):
483
+ angle = progress * math.tau * 0.3 + idx * (math.tau / 2)
484
+ ox = int(math.cos(angle) * offset)
485
+ oy = int(math.sin(angle) * offset)
486
+ pygame.draw.circle(swirl, color, (cx + ox, cy + oy), swirl_radius, width=2)
487
+ world_rect = pygame.Rect(0, 0, 1, 1)
488
+ world_rect.center = center
489
+ screen_center = camera.apply_rect(world_rect).center
490
+ screen.blit(swirl, swirl.get_rect(center=screen_center))
491
+
492
+
493
+ def _draw_falling_fx(
494
+ screen: surface.Surface,
495
+ camera: Camera,
496
+ falling_zombies: list[FallingZombie],
497
+ flashlight_count: int,
498
+ dust_rings: list[DustRing],
499
+ ) -> None:
500
+ if not falling_zombies and not dust_rings:
501
+ return
502
+ now = pygame.time.get_ticks()
503
+ for fall in falling_zombies:
504
+ pre_fx_ms = max(0, fall.pre_fx_ms)
505
+ fall_duration_ms = max(1, fall.fall_duration_ms)
506
+ fall_start = fall.started_at_ms + pre_fx_ms
507
+ impact_at = fall_start + fall_duration_ms
508
+ if now < fall_start:
509
+ if flashlight_count > 0 and pre_fx_ms > 0:
510
+ fx_progress = max(0.0, min(1.0, (now - fall.started_at_ms) / pre_fx_ms))
511
+ _draw_fall_whirlwind(screen, camera, fall.target_pos, fx_progress)
512
+ continue
513
+ if now >= impact_at:
514
+ continue
515
+ fall_progress = max(0.0, min(1.0, (now - fall_start) / fall_duration_ms))
516
+ eased = 1.0 - (1.0 - fall_progress) * (1.0 - fall_progress)
517
+ x = fall.target_pos[0]
518
+ y = int(fall.start_pos[1] + (fall.target_pos[1] - fall.start_pos[1]) * eased)
519
+ world_rect = pygame.Rect(0, 0, ZOMBIE_RADIUS * 2, ZOMBIE_RADIUS * 2)
520
+ world_rect.center = (x, y)
521
+ screen_rect = camera.apply_rect(world_rect)
522
+ pygame.draw.circle(
523
+ screen,
524
+ FALLING_ZOMBIE_COLOR,
525
+ screen_rect.center,
526
+ ZOMBIE_RADIUS,
527
+ )
528
+
529
+ for ring in list(dust_rings):
530
+ elapsed = now - ring.started_at_ms
531
+ if elapsed >= ring.duration_ms:
532
+ dust_rings.remove(ring)
533
+ continue
534
+ progress = max(0.0, min(1.0, elapsed / ring.duration_ms))
535
+ alpha = int(max(0, min(255, FALLING_DUST_COLOR[3] * (1.0 - progress))))
536
+ if alpha <= 0:
537
+ continue
538
+ radius = int(ZOMBIE_RADIUS * (0.7 + progress * 1.9))
539
+ color = (
540
+ FALLING_DUST_COLOR[0],
541
+ FALLING_DUST_COLOR[1],
542
+ FALLING_DUST_COLOR[2],
543
+ alpha,
544
+ )
545
+ world_rect = pygame.Rect(0, 0, 1, 1)
546
+ world_rect.center = ring.pos
547
+ screen_center = camera.apply_rect(world_rect).center
548
+ pygame.draw.circle(screen, color, screen_center, radius, width=2)
549
+
550
+
362
551
  def _draw_hint_arrow(
363
552
  screen: surface.Surface,
364
553
  camera: Camera,
@@ -411,6 +600,7 @@ def _draw_status_bar(
411
600
  seed: int | None = None,
412
601
  debug_mode: bool = False,
413
602
  zombie_group: sprite.Group | None = None,
603
+ falling_spawn_carry: int | None = None,
414
604
  ) -> None:
415
605
  """Render a compact status bar with current config flags and stage info."""
416
606
  bar_rect = pygame.Rect(
@@ -429,7 +619,11 @@ def _draw_status_bar(
429
619
  steel_on = config.get("steel_beams", {}).get("enabled", False)
430
620
  if stage:
431
621
  # Keep the label compact for the status bar
432
- stage_label = f"#{stage.id[-1]}" if stage.id.startswith("stage") else stage.id
622
+ if stage.id.startswith("stage"):
623
+ stage_suffix = stage.id.removeprefix("stage")
624
+ stage_label = f"#{stage_suffix}" if stage_suffix else stage.id
625
+ else:
626
+ stage_label = stage.id
433
627
  else:
434
628
  stage_label = "#1"
435
629
 
@@ -443,14 +637,15 @@ def _draw_status_bar(
443
637
  if steel_on:
444
638
  parts.append(tr("status.steel"))
445
639
  if debug_mode:
446
- parts.append(tr("status.debug"))
447
640
  if zombie_group is not None:
448
- zombies = [z for z in zombie_group if getattr(z, "alive", lambda: True)()]
641
+ zombies = [z for z in zombie_group if z.alive()]
449
642
  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))
643
+ tracker = sum(1 for z in zombies if z.tracker)
644
+ wall = sum(1 for z in zombies if z.wall_follower)
452
645
  normal = max(0, total - tracker - wall)
453
646
  parts.append(f"Z:{total} N:{normal} T:{tracker} W:{wall}")
647
+ if falling_spawn_carry is not None:
648
+ parts.append(f"C:{max(0, falling_spawn_carry)}")
454
649
 
455
650
  status_text = " | ".join(parts)
456
651
  color = LIGHT_GRAY
@@ -472,45 +667,15 @@ def _draw_status_bar(
472
667
  print(f"Error rendering status bar: {e}")
473
668
 
474
669
 
475
- def draw(
476
- assets: RenderAssets,
670
+ def _draw_play_area(
477
671
  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
-
672
+ camera: Camera,
673
+ assets: RenderAssets,
674
+ palette: Any,
675
+ outer_rect: tuple[int, int, int, int],
676
+ outside_rects: list[pygame.Rect],
677
+ fall_spawn_cells: set[tuple[int, int]],
678
+ ) -> tuple[int, int, int, int, set[tuple[int, int]]]:
514
679
  xs, ys, xe, ye = outer_rect
515
680
  xs //= assets.internal_wall_grid_snap
516
681
  ys //= assets.internal_wall_grid_snap
@@ -556,38 +721,140 @@ def draw(
556
721
  for x in range(start_x, end_x):
557
722
  if (x, y) in outside_cells:
558
723
  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,
724
+ use_secondary = ((x // 2) + (y // 2)) % 2 == 0
725
+ if (x, y) in fall_spawn_cells:
726
+ color = (
727
+ palette.fall_zone_secondary
728
+ if use_secondary
729
+ else palette.fall_zone_primary
569
730
  )
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,
731
+ elif not use_secondary:
732
+ continue
733
+ else:
734
+ color = palette.floor_secondary
735
+ lx, ly = (
736
+ x * assets.internal_wall_grid_snap,
737
+ y * assets.internal_wall_grid_snap,
738
+ )
739
+ r = pygame.Rect(
740
+ lx,
741
+ ly,
742
+ assets.internal_wall_grid_snap,
743
+ assets.internal_wall_grid_snap,
586
744
  )
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)
745
+ sr = camera.apply_rect(r)
746
+ if sr.colliderect(screen.get_rect()):
747
+ pygame.draw.rect(screen, color, sr)
748
+
749
+ return xs, ys, xe, ye, outside_cells
750
+
751
+
752
+ def abs_clip(value: float, min_v: float, max_v: float) -> float:
753
+ value_sign = 1.0 if value >= 0.0 else -1.0
754
+ value = abs(value)
755
+ if value < min_v:
756
+ value = min_v
757
+ elif value > max_v:
758
+ value = max_v
759
+ return value_sign * value
760
+
761
+
762
+ def _draw_wall_shadows(
763
+ screen: surface.Surface,
764
+ camera: Camera,
765
+ *,
766
+ wall_cells: set[tuple[int, int]],
767
+ wall_group: sprite.Group | None,
768
+ outer_wall_cells: set[tuple[int, int]] | None,
769
+ cell_size: int,
770
+ light_source_pos: tuple[int, int] | None,
771
+ alpha: int = 68,
772
+ ) -> None:
773
+ if not wall_cells or cell_size <= 0 or light_source_pos is None:
774
+ return
775
+ inner_wall_cells = set(wall_cells)
776
+ if outer_wall_cells:
777
+ inner_wall_cells.difference_update(outer_wall_cells)
778
+ if wall_group and cell_size > 0:
779
+ for wall in wall_group:
780
+ if isinstance(wall, SteelBeam):
781
+ cell_x = int(wall.rect.centerx // cell_size)
782
+ cell_y = int(wall.rect.centery // cell_size)
783
+ inner_wall_cells.add((cell_x, cell_y))
784
+ if not inner_wall_cells:
785
+ return
786
+ base_shadow_size = max(cell_size + 2, int(cell_size * 1.35))
787
+ shadow_size = max(1, int(base_shadow_size * 1.5))
788
+ shadow_surface = _get_shadow_tile_surface(shadow_size, 255, edge_softness=0.12)
789
+ shadow_layer = _get_shadow_layer(screen.get_size())
790
+ shadow_layer.fill((0, 0, 0, 0))
791
+ screen_rect = screen.get_rect()
792
+ px, py = light_source_pos
793
+ drew = False
794
+ clip_max = shadow_size * 0.25
795
+ for cell_x, cell_y in inner_wall_cells:
796
+ world_x = cell_x * cell_size
797
+ world_y = cell_y * cell_size
798
+ wall_rect = pygame.Rect(world_x, world_y, cell_size, cell_size)
799
+ wall_screen_rect = camera.apply_rect(wall_rect)
800
+ if not wall_screen_rect.colliderect(screen_rect):
801
+ continue
802
+ center_x = world_x + cell_size / 2
803
+ center_y = world_y + cell_size / 2
804
+ dx = (center_x - px) * 0.5
805
+ dy = (center_y - py) * 0.5
806
+ dx = int(abs_clip(dx, 0, clip_max))
807
+ dy = int(abs_clip(dy, 0, clip_max))
808
+ shadow_rect = pygame.Rect(0, 0, shadow_size, shadow_size)
809
+ shadow_rect.center = (
810
+ int(center_x + dx),
811
+ int(center_y + dy),
812
+ )
813
+ shadow_screen_rect = camera.apply_rect(shadow_rect)
814
+ if not shadow_screen_rect.colliderect(screen_rect):
815
+ continue
816
+ shadow_layer.blit(shadow_surface, shadow_screen_rect.topleft)
817
+ drew = True
818
+ if drew:
819
+ shadow_layer.set_alpha(alpha)
820
+ screen.blit(shadow_layer, (0, 0))
821
+
822
+
823
+ def _draw_footprints(
824
+ screen: surface.Surface,
825
+ camera: Camera,
826
+ assets: RenderAssets,
827
+ footprints: list[Footprint],
828
+ *,
829
+ config: dict[str, Any],
830
+ ) -> None:
831
+ if not config.get("footprints", {}).get("enabled", True):
832
+ return
833
+ now = pygame.time.get_ticks()
834
+ for fp in footprints:
835
+ age = now - fp.time
836
+ fade = 1 - (age / assets.footprint_lifetime_ms)
837
+ fade = max(assets.footprint_min_fade, fade)
838
+ color = tuple(int(c * fade) for c in FOOTPRINT_COLOR)
839
+ fp_rect = pygame.Rect(
840
+ fp.pos[0] - assets.footprint_radius,
841
+ fp.pos[1] - assets.footprint_radius,
842
+ assets.footprint_radius * 2,
843
+ assets.footprint_radius * 2,
844
+ )
845
+ sr = camera.apply_rect(fp_rect)
846
+ if sr.colliderect(screen.get_rect().inflate(30, 30)):
847
+ pygame.draw.circle(screen, color, sr.center, assets.footprint_radius)
590
848
 
849
+
850
+ def _draw_entities(
851
+ screen: surface.Surface,
852
+ camera: Camera,
853
+ all_sprites: sprite.LayeredUpdates,
854
+ player: Player,
855
+ *,
856
+ has_fuel: bool,
857
+ ) -> pygame.Rect:
591
858
  screen_rect_inflated = screen.get_rect().inflate(100, 100)
592
859
  player_screen_rect: pygame.Rect | None = None
593
860
  for entity in all_sprites:
@@ -596,177 +863,273 @@ def draw(
596
863
  screen.blit(entity.image, sprite_screen_rect)
597
864
  if entity is player:
598
865
  player_screen_rect = sprite_screen_rect
866
+ _draw_fuel_indicator(
867
+ screen,
868
+ player_screen_rect,
869
+ has_fuel=has_fuel,
870
+ in_car=player.in_car,
871
+ )
872
+ return player_screen_rect or camera.apply_rect(player.rect)
599
873
 
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
874
 
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
- )
875
+ def _draw_fuel_indicator(
876
+ screen: surface.Surface,
877
+ player_screen_rect: pygame.Rect,
878
+ *,
879
+ has_fuel: bool,
880
+ in_car: bool,
881
+ ) -> None:
882
+ if not has_fuel or in_car:
883
+ return
884
+ indicator_size = 4
885
+ padding = 1
886
+ indicator_rect = pygame.Rect(
887
+ player_screen_rect.right - indicator_size - padding,
888
+ player_screen_rect.bottom - indicator_size - padding,
889
+ indicator_size,
890
+ indicator_size,
891
+ )
892
+ pygame.draw.rect(screen, YELLOW, indicator_rect)
893
+ pygame.draw.rect(screen, (180, 160, 40), indicator_rect, width=1)
631
894
 
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
895
 
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
- )
896
+ def _draw_hint_indicator(
897
+ screen: surface.Surface,
898
+ camera: Camera,
899
+ assets: RenderAssets,
900
+ player: Player,
901
+ hint_target: tuple[int, int] | None,
902
+ *,
903
+ hint_color: tuple[int, int, int],
904
+ stage: Stage | None,
905
+ flashlight_count: int,
906
+ ) -> None:
907
+ if not hint_target:
908
+ return
909
+ current_fov_scale = _get_fog_scale(
910
+ assets,
911
+ stage,
912
+ flashlight_count,
913
+ )
914
+ hint_ring_radius = assets.fov_radius * 0.5 * current_fov_scale
915
+ _draw_hint_arrow(
916
+ screen,
917
+ camera,
918
+ assets,
919
+ player,
920
+ hint_target,
921
+ color=hint_color,
922
+ ring_radius=hint_ring_radius,
923
+ )
666
924
 
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
925
+
926
+ def _draw_fog_of_war(
927
+ screen: surface.Surface,
928
+ camera: Camera,
929
+ assets: RenderAssets,
930
+ fog_surfaces: dict[str, Any],
931
+ fov_target: pygame.sprite.Sprite | None,
932
+ *,
933
+ stage: Stage | None,
934
+ flashlight_count: int,
935
+ dawn_ready: bool,
936
+ ) -> None:
937
+ if fov_target is None:
938
+ return
939
+ fov_center_on_screen = list(camera.apply(fov_target).center)
940
+ cam_rect = camera.camera
941
+ horizontal_span = camera.width - assets.screen_width
942
+ vertical_span = camera.height - assets.screen_height
943
+ if horizontal_span <= 0 or (cam_rect.x != 0 and cam_rect.x != -horizontal_span):
944
+ fov_center_on_screen[0] = assets.screen_width // 2
945
+ if vertical_span <= 0 or (cam_rect.y != 0 and cam_rect.y != -vertical_span):
946
+ fov_center_on_screen[1] = assets.screen_height // 2
947
+ fov_center_tuple = (int(fov_center_on_screen[0]), int(fov_center_on_screen[1]))
948
+ if dawn_ready:
949
+ profile = FogProfile.DAWN
950
+ else:
951
+ profile = FogProfile._from_flashlight_count(flashlight_count)
952
+ overlay = _get_fog_overlay_surfaces(
953
+ fog_surfaces,
954
+ assets,
955
+ profile,
956
+ stage=stage,
957
+ )
958
+ combined_surface: surface.Surface = overlay["combined"]
959
+ screen.blit(
960
+ combined_surface,
961
+ combined_surface.get_rect(center=fov_center_tuple),
962
+ )
963
+
964
+
965
+ def _draw_need_fuel_message(
966
+ screen: surface.Surface,
967
+ assets: RenderAssets,
968
+ *,
969
+ has_fuel: bool,
970
+ fuel_message_until: int,
971
+ elapsed_play_ms: int,
972
+ ) -> None:
973
+ if has_fuel or fuel_message_until <= elapsed_play_ms:
974
+ return
975
+ show_message(
976
+ screen,
977
+ tr("hud.need_fuel"),
978
+ 18,
979
+ ORANGE,
980
+ (assets.screen_width // 2, assets.screen_height // 2),
981
+ )
982
+
983
+
984
+ def _draw_objective(lines: list[str], *, screen: surface.Surface) -> None:
985
+ try:
986
+ font_settings = get_font_settings()
987
+ font = load_font(font_settings.resource, font_settings.scaled_size(11))
988
+ y = 8
989
+ for line in lines:
990
+ text_surface = font.render(line, False, YELLOW)
991
+ text_rect = text_surface.get_rect(topleft=(12, y))
992
+ screen.blit(text_surface, text_rect)
993
+ y += text_rect.height + 4
994
+ except pygame.error as e:
995
+ print(f"Error rendering objective: {e}")
996
+
997
+
998
+ def _draw_endurance_timer(
999
+ screen: surface.Surface,
1000
+ assets: RenderAssets,
1001
+ *,
1002
+ stage: Stage | None,
1003
+ state: Any,
1004
+ ) -> None:
1005
+ if not (stage and stage.endurance_stage):
1006
+ return
1007
+ goal_ms = state.endurance_goal_ms
1008
+ if goal_ms <= 0:
1009
+ return
1010
+ elapsed_ms = max(0, min(goal_ms, state.endurance_elapsed_ms))
1011
+ remaining_ms = max(0, goal_ms - elapsed_ms)
1012
+ padding = 12
1013
+ bar_height = 8
1014
+ y_pos = assets.screen_height - assets.status_bar_height - bar_height - 10
1015
+ bar_rect = pygame.Rect(
1016
+ padding,
1017
+ y_pos,
1018
+ assets.screen_width - padding * 2,
1019
+ bar_height,
1020
+ )
1021
+ track_surface = pygame.Surface((bar_rect.width, bar_rect.height), pygame.SRCALPHA)
1022
+ track_surface.fill((0, 0, 0, 140))
1023
+ screen.blit(track_surface, bar_rect.topleft)
1024
+ progress_ratio = elapsed_ms / goal_ms if goal_ms else 0.0
1025
+ progress_width = int(bar_rect.width * max(0.0, min(1.0, progress_ratio)))
1026
+ if progress_width > 0:
1027
+ fill_color = (120, 20, 20)
1028
+ if state.dawn_ready:
1029
+ fill_color = (25, 40, 120)
1030
+ fill_rect = pygame.Rect(
1031
+ bar_rect.left,
1032
+ bar_rect.top,
1033
+ progress_width,
1034
+ bar_rect.height,
699
1035
  )
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,
1036
+ pygame.draw.rect(screen, fill_color, fill_rect)
1037
+ display_ms = int(remaining_ms * SURVIVAL_FAKE_CLOCK_RATIO)
1038
+ display_ms = max(0, display_ms)
1039
+ display_hours = display_ms // 3_600_000
1040
+ display_minutes = (display_ms % 3_600_000) // 60_000
1041
+ display_label = f"{int(display_hours):02d}:{int(display_minutes):02d}"
1042
+ timer_text = tr("hud.endurance_timer_label", time=display_label)
1043
+ try:
1044
+ font_settings = get_font_settings()
1045
+ font = load_font(font_settings.resource, font_settings.scaled_size(12))
1046
+ text_surface = font.render(timer_text, False, LIGHT_GRAY)
1047
+ text_rect = text_surface.get_rect(left=bar_rect.left, bottom=bar_rect.top - 2)
1048
+ screen.blit(text_surface, text_rect)
1049
+ if state.time_accel_active:
1050
+ accel_text = tr("hud.time_accel")
1051
+ accel_surface = font.render(accel_text, False, YELLOW)
1052
+ accel_rect = accel_surface.get_rect(
1053
+ right=bar_rect.right, bottom=bar_rect.top - 2
713
1054
  )
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
1055
+ screen.blit(accel_surface, accel_rect)
1056
+ else:
1057
+ hint_text = tr("hud.time_accel_hint")
1058
+ hint_surface = font.render(hint_text, False, LIGHT_GRAY)
1059
+ hint_rect = hint_surface.get_rect(
1060
+ right=bar_rect.right, bottom=bar_rect.top - 2
727
1061
  )
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}")
745
-
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,
1062
+ screen.blit(hint_surface, hint_rect)
1063
+ except pygame.error as e:
1064
+ print(f"Error rendering endurance timer: {e}")
1065
+
1066
+
1067
+ def _draw_time_accel_indicator(
1068
+ screen: surface.Surface,
1069
+ assets: RenderAssets,
1070
+ *,
1071
+ stage: Stage | None,
1072
+ state: Any,
1073
+ ) -> None:
1074
+ if stage and stage.endurance_stage:
1075
+ return
1076
+ try:
1077
+ font_settings = get_font_settings()
1078
+ font = load_font(font_settings.resource, font_settings.scaled_size(12))
1079
+ if state.time_accel_active:
1080
+ text = tr("hud.time_accel")
1081
+ color = YELLOW
1082
+ else:
1083
+ text = tr("hud.time_accel_hint")
1084
+ color = LIGHT_GRAY
1085
+ text_surface = font.render(text, False, color)
1086
+ bottom_margin = assets.status_bar_height + 6
1087
+ text_rect = text_surface.get_rect(
1088
+ right=assets.screen_width - 12,
1089
+ bottom=assets.screen_height - bottom_margin,
1090
+ )
1091
+ screen.blit(text_surface, text_rect)
1092
+ except pygame.error as e:
1093
+ print(f"Error rendering acceleration indicator: {e}")
1094
+
1095
+
1096
+ def _draw_survivor_messages(
1097
+ screen: surface.Surface,
1098
+ assets: RenderAssets,
1099
+ survivor_messages: list[dict[str, Any]],
1100
+ ) -> None:
1101
+ if not survivor_messages:
1102
+ return
1103
+ try:
1104
+ font_settings = get_font_settings()
1105
+ font = load_font(font_settings.resource, font_settings.scaled_size(14))
1106
+ base_y = assets.screen_height // 2 - 70
1107
+ for idx, message in enumerate(survivor_messages[:3]):
1108
+ text = message.get("text", "")
1109
+ if not text:
1110
+ continue
1111
+ msg_surface = font.render(text, False, ORANGE)
1112
+ msg_rect = msg_surface.get_rect(
1113
+ center=(assets.screen_width // 2, base_y + idx * 18)
763
1114
  )
764
- screen.blit(text_surface, text_rect)
765
- except pygame.error as e:
766
- print(f"Error rendering acceleration indicator: {e}")
1115
+ screen.blit(msg_surface, msg_rect)
1116
+ except pygame.error as e:
1117
+ print(f"Error rendering survivor message: {e}")
767
1118
 
1119
+
1120
+ def _build_objective_lines(
1121
+ *,
1122
+ stage: Stage | None,
1123
+ state: Any,
1124
+ player: Player,
1125
+ active_car: Car | None,
1126
+ has_fuel: bool,
1127
+ buddy_onboard: int,
1128
+ buddy_required: int,
1129
+ survivors_onboard: int,
1130
+ ) -> list[str]:
768
1131
  objective_lines: list[str] = []
769
- if stage and stage.survival_stage:
1132
+ if stage and stage.endurance_stage:
770
1133
  if state.dawn_ready:
771
1134
  objective_lines.append(tr("objectives.get_outside"))
772
1135
  else:
@@ -807,29 +1170,141 @@ def draw(
807
1170
  objective_lines.append(
808
1171
  tr("objectives.survivors_onboard", count=survivors_onboard, limit=limit)
809
1172
  )
1173
+ return objective_lines
810
1174
 
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()
1175
+
1176
+ def draw(
1177
+ assets: RenderAssets,
1178
+ screen: surface.Surface,
1179
+ game_data: GameData,
1180
+ *,
1181
+ config: dict[str, Any],
1182
+ hint_target: tuple[int, int] | None = None,
1183
+ hint_color: tuple[int, int, int] | None = None,
1184
+ do_flip: bool = True,
1185
+ present_fn: Callable[[surface.Surface], None] | None = None,
1186
+ ) -> None:
1187
+ hint_color = hint_color or YELLOW
1188
+ state = game_data.state
1189
+ player = game_data.player
1190
+ if player is None:
1191
+ raise ValueError("draw requires an active player on game_data")
1192
+
1193
+ camera = game_data.camera
1194
+ stage = game_data.stage
1195
+ outer_rect = game_data.layout.outer_rect
1196
+ outside_rects = game_data.layout.outside_rects or []
1197
+ all_sprites = game_data.groups.all_sprites
1198
+ fog_surfaces = game_data.fog
1199
+ footprints = state.footprints
1200
+ has_fuel = state.has_fuel
1201
+ flashlight_count = state.flashlight_count
1202
+ elapsed_play_ms = state.elapsed_play_ms
1203
+ fuel_message_until = state.fuel_message_until
1204
+ buddy_onboard = state.buddy_onboard
1205
+ buddy_required = stage.buddy_required_count if stage else 0
1206
+ survivors_onboard = state.survivors_onboard
1207
+ survivor_messages = list(state.survivor_messages)
1208
+ zombie_group = game_data.groups.zombie_group
1209
+ active_car = game_data.car if game_data.car and game_data.car.alive() else None
1210
+ if player.in_car and game_data.car and game_data.car.alive():
1211
+ fov_target = game_data.car
831
1212
  else:
832
- _render_time_accel_indicator()
1213
+ fov_target = player
1214
+
1215
+ palette = get_environment_palette(state.ambient_palette_key)
1216
+ screen.fill(palette.outside)
1217
+
1218
+ _draw_play_area(
1219
+ screen,
1220
+ camera,
1221
+ assets,
1222
+ palette,
1223
+ outer_rect,
1224
+ outside_rects,
1225
+ game_data.layout.fall_spawn_cells,
1226
+ )
1227
+ _draw_wall_shadows(
1228
+ screen,
1229
+ camera,
1230
+ wall_cells=game_data.layout.wall_cells,
1231
+ wall_group=game_data.groups.wall_group,
1232
+ outer_wall_cells=game_data.layout.outer_wall_cells,
1233
+ cell_size=game_data.cell_size,
1234
+ light_source_pos=(
1235
+ None
1236
+ if (stage and stage.endurance_stage and state.dawn_ready)
1237
+ else fov_target.rect.center
1238
+ )
1239
+ if fov_target
1240
+ else None,
1241
+ )
1242
+ _draw_footprints(
1243
+ screen,
1244
+ camera,
1245
+ assets,
1246
+ footprints,
1247
+ config=config,
1248
+ )
1249
+ _draw_entities(
1250
+ screen,
1251
+ camera,
1252
+ all_sprites,
1253
+ player,
1254
+ has_fuel=has_fuel,
1255
+ )
1256
+
1257
+ _draw_falling_fx(
1258
+ screen,
1259
+ camera,
1260
+ state.falling_zombies,
1261
+ state.flashlight_count,
1262
+ state.dust_rings,
1263
+ )
1264
+
1265
+ _draw_hint_indicator(
1266
+ screen,
1267
+ camera,
1268
+ assets,
1269
+ player,
1270
+ hint_target,
1271
+ hint_color=hint_color,
1272
+ stage=stage,
1273
+ flashlight_count=flashlight_count,
1274
+ )
1275
+ _draw_fog_of_war(
1276
+ screen,
1277
+ camera,
1278
+ assets,
1279
+ fog_surfaces,
1280
+ fov_target,
1281
+ stage=stage,
1282
+ flashlight_count=flashlight_count,
1283
+ dawn_ready=state.dawn_ready,
1284
+ )
1285
+ _draw_need_fuel_message(
1286
+ screen,
1287
+ assets,
1288
+ has_fuel=has_fuel,
1289
+ fuel_message_until=fuel_message_until,
1290
+ elapsed_play_ms=elapsed_play_ms,
1291
+ )
1292
+
1293
+ objective_lines = _build_objective_lines(
1294
+ stage=stage,
1295
+ state=state,
1296
+ player=player,
1297
+ active_car=active_car,
1298
+ has_fuel=has_fuel,
1299
+ buddy_onboard=buddy_onboard,
1300
+ buddy_required=buddy_required,
1301
+ survivors_onboard=survivors_onboard,
1302
+ )
1303
+ if objective_lines:
1304
+ _draw_objective(objective_lines, screen=screen)
1305
+ _draw_survivor_messages(screen, assets, survivor_messages)
1306
+ _draw_endurance_timer(screen, assets, stage=stage, state=state)
1307
+ _draw_time_accel_indicator(screen, assets, stage=stage, state=state)
833
1308
  _draw_status_bar(
834
1309
  screen,
835
1310
  assets,
@@ -838,6 +1313,7 @@ def draw(
838
1313
  seed=state.seed,
839
1314
  debug_mode=state.debug_mode,
840
1315
  zombie_group=zombie_group,
1316
+ falling_spawn_carry=state.falling_spawn_carry,
841
1317
  )
842
1318
  if do_flip:
843
1319
  if present_fn: