zombie-escape 1.10.0__py3-none-any.whl → 1.12.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
@@ -23,11 +23,21 @@ from .entities import (
23
23
  Flashlight,
24
24
  FuelCan,
25
25
  Player,
26
+ Shoes,
26
27
  SteelBeam,
27
28
  Survivor,
28
29
  Wall,
30
+ Zombie,
31
+ )
32
+ from .entities_constants import (
33
+ FLASHLIGHT_HEIGHT,
34
+ FLASHLIGHT_WIDTH,
35
+ FUEL_CAN_HEIGHT,
36
+ FUEL_CAN_WIDTH,
37
+ SHOES_HEIGHT,
38
+ SHOES_WIDTH,
39
+ ZOMBIE_RADIUS,
29
40
  )
30
- from .entities_constants import ZOMBIE_RADIUS
31
41
  from .font_utils import load_font
32
42
  from .gameplay_constants import (
33
43
  DEFAULT_FLASHLIGHT_SPAWN_COUNT,
@@ -36,15 +46,37 @@ from .gameplay_constants import (
36
46
  from .localization import get_font_settings
37
47
  from .localization import translate as tr
38
48
  from .models import DustRing, FallingZombie, Footprint, GameData, Stage
39
- from .render_assets import RenderAssets, resolve_steel_beam_colors, resolve_wall_colors
49
+ from .render_assets import (
50
+ RenderAssets,
51
+ build_flashlight_surface,
52
+ build_fuel_can_surface,
53
+ build_shoes_surface,
54
+ resolve_steel_beam_colors,
55
+ resolve_wall_colors,
56
+ )
40
57
  from .render_constants import (
58
+ ENTITY_SHADOW_ALPHA,
59
+ ENTITY_SHADOW_EDGE_SOFTNESS,
60
+ ENTITY_SHADOW_RADIUS_MULT,
41
61
  FALLING_DUST_COLOR,
42
62
  FALLING_WHIRLWIND_COLOR,
43
63
  FALLING_ZOMBIE_COLOR,
64
+ FLASHLIGHT_FOG_SCALE_ONE,
65
+ FLASHLIGHT_FOG_SCALE_TWO,
66
+ PLAYER_SHADOW_ALPHA_MULT,
67
+ PLAYER_SHADOW_RADIUS_MULT,
68
+ SHADOW_MIN_RATIO,
69
+ SHADOW_OVERSAMPLE,
70
+ SHADOW_RADIUS_RATIO,
71
+ SHADOW_STEPS,
44
72
  )
45
73
 
46
74
  _SHADOW_TILE_CACHE: dict[tuple[int, int, float], surface.Surface] = {}
47
75
  _SHADOW_LAYER_CACHE: dict[tuple[int, int], surface.Surface] = {}
76
+ _SHADOW_CIRCLE_CACHE: dict[tuple[int, int, float], surface.Surface] = {}
77
+ _HUD_ICON_CACHE: dict[str, surface.Surface] = {}
78
+
79
+ HUD_ICON_SIZE = 12
48
80
 
49
81
 
50
82
  def _get_shadow_tile_surface(
@@ -57,34 +89,45 @@ def _get_shadow_tile_surface(
57
89
  if key in _SHADOW_TILE_CACHE:
58
90
  return _SHADOW_TILE_CACHE[key]
59
91
  size = key[0]
60
- surf = pygame.Surface((size, size), pygame.SRCALPHA)
92
+ oversample = SHADOW_OVERSAMPLE
93
+ render_size = size * oversample
94
+ render_surf = pygame.Surface((render_size, render_size), pygame.SRCALPHA)
61
95
  base_alpha = key[1]
62
96
  if edge_softness <= 0:
63
- surf.fill((0, 0, 0, base_alpha))
97
+ render_surf.fill((0, 0, 0, base_alpha))
98
+ if oversample > 1:
99
+ surf = pygame.transform.smoothscale(render_surf, (size, size))
100
+ else:
101
+ surf = render_surf
64
102
  _SHADOW_TILE_CACHE[key] = surf
65
103
  return surf
66
104
 
67
105
  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
106
+ fade_band = max(1, int(render_size * softness))
107
+ base_radius = max(1, int(render_size * SHADOW_RADIUS_RATIO))
108
+
109
+ render_surf.fill((0, 0, 0, 0))
110
+ steps = SHADOW_STEPS
111
+ min_ratio = SHADOW_MIN_RATIO
112
+ for idx in range(steps):
113
+ t = idx / (steps - 1) if steps > 1 else 1.0
114
+ inset = int(fade_band * t)
115
+ rect_size = render_size - inset * 2
73
116
  if rect_size <= 0:
74
- return
117
+ continue
75
118
  radius = max(0, base_radius - inset)
119
+ layer_alpha = int(base_alpha * (min_ratio + (1.0 - min_ratio) * t))
76
120
  pygame.draw.rect(
77
- surf,
121
+ render_surf,
78
122
  (0, 0, 0, layer_alpha),
79
123
  pygame.Rect(inset, inset, rect_size, rect_size),
80
124
  border_radius=radius,
81
125
  )
82
126
 
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)
127
+ if oversample > 1:
128
+ surf = pygame.transform.smoothscale(render_surf, (size, size))
129
+ else:
130
+ surf = render_surf
88
131
  _SHADOW_TILE_CACHE[key] = surf
89
132
  return surf
90
133
 
@@ -98,6 +141,98 @@ def _get_shadow_layer(size: tuple[int, int]) -> surface.Surface:
98
141
  return layer
99
142
 
100
143
 
144
+ def _scale_icon_to_box(icon: surface.Surface, size: int) -> surface.Surface:
145
+ target_size = max(1, size)
146
+ width = max(1, icon.get_width())
147
+ height = max(1, icon.get_height())
148
+ scale = min(target_size / width, target_size / height)
149
+ target_width = max(1, int(width * scale))
150
+ target_height = max(1, int(height * scale))
151
+ scaled = pygame.transform.smoothscale(icon, (target_width, target_height))
152
+ boxed = pygame.Surface((target_size, target_size), pygame.SRCALPHA)
153
+ boxed.blit(
154
+ scaled,
155
+ (
156
+ (target_size - target_width) // 2,
157
+ (target_size - target_height) // 2,
158
+ ),
159
+ )
160
+ return boxed
161
+
162
+
163
+ def _get_hud_icon(kind: str) -> surface.Surface:
164
+ cached = _HUD_ICON_CACHE.get(kind)
165
+ if cached is not None:
166
+ return cached
167
+ if kind == "fuel":
168
+ icon = build_fuel_can_surface(FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT)
169
+ elif kind == "flashlight":
170
+ icon = build_flashlight_surface(FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT)
171
+ elif kind == "shoes":
172
+ icon = build_shoes_surface(SHOES_WIDTH, SHOES_HEIGHT)
173
+ else:
174
+ icon = pygame.Surface((1, 1), pygame.SRCALPHA)
175
+ icon = _scale_icon_to_box(icon, HUD_ICON_SIZE)
176
+ _HUD_ICON_CACHE[kind] = icon
177
+ return icon
178
+
179
+
180
+ def _get_shadow_circle_surface(
181
+ radius: int,
182
+ alpha: int,
183
+ *,
184
+ edge_softness: float = 0.12,
185
+ ) -> surface.Surface:
186
+ key = (max(1, radius), max(0, min(255, alpha)), edge_softness)
187
+ if key in _SHADOW_CIRCLE_CACHE:
188
+ return _SHADOW_CIRCLE_CACHE[key]
189
+ radius = key[0]
190
+ oversample = SHADOW_OVERSAMPLE
191
+ render_radius = radius * oversample
192
+ render_size = render_radius * 2
193
+ render_surf = pygame.Surface((render_size, render_size), pygame.SRCALPHA)
194
+ base_alpha = key[1]
195
+ if edge_softness <= 0:
196
+ pygame.draw.circle(
197
+ render_surf,
198
+ (0, 0, 0, base_alpha),
199
+ (render_radius, render_radius),
200
+ render_radius,
201
+ )
202
+ if oversample > 1:
203
+ surf = pygame.transform.smoothscale(render_surf, (radius * 2, radius * 2))
204
+ else:
205
+ surf = render_surf
206
+ _SHADOW_CIRCLE_CACHE[key] = surf
207
+ return surf
208
+
209
+ softness = max(0.0, min(1.0, edge_softness))
210
+ fade_band = max(1, int(render_radius * softness))
211
+ steps = SHADOW_STEPS
212
+ min_ratio = SHADOW_MIN_RATIO
213
+ render_surf.fill((0, 0, 0, 0))
214
+ for idx in range(steps):
215
+ t = idx / (steps - 1) if steps > 1 else 1.0
216
+ inset = int(fade_band * t)
217
+ circle_radius = render_radius - inset
218
+ if circle_radius <= 0:
219
+ continue
220
+ layer_alpha = int(base_alpha * (min_ratio + (1.0 - min_ratio) * t))
221
+ pygame.draw.circle(
222
+ render_surf,
223
+ (0, 0, 0, layer_alpha),
224
+ (render_radius, render_radius),
225
+ circle_radius,
226
+ )
227
+
228
+ if oversample > 1:
229
+ surf = pygame.transform.smoothscale(render_surf, (radius * 2, radius * 2))
230
+ else:
231
+ surf = render_surf
232
+ _SHADOW_CIRCLE_CACHE[key] = surf
233
+ return surf
234
+
235
+
101
236
  def show_message(
102
237
  screen: surface.Surface,
103
238
  text: str,
@@ -134,7 +269,7 @@ def draw_level_overview(
134
269
  *,
135
270
  fuel: FuelCan | None = None,
136
271
  flashlights: list[Flashlight] | None = None,
137
- stage: Stage | None = None,
272
+ shoes: list[Shoes] | None = None,
138
273
  buddies: list[Survivor] | None = None,
139
274
  survivors: list[Survivor] | None = None,
140
275
  palette_key: str | None = None,
@@ -180,11 +315,15 @@ def draw_level_overview(
180
315
  for flashlight in flashlights:
181
316
  if flashlight.alive():
182
317
  pygame.draw.rect(
183
- surface, (240, 230, 150), flashlight.rect, border_radius=2
318
+ surface, YELLOW, flashlight.rect, border_radius=2
184
319
  )
185
320
  pygame.draw.rect(
186
321
  surface, BLACK, flashlight.rect, width=2, border_radius=2
187
322
  )
323
+ if shoes:
324
+ for item in shoes:
325
+ if item.alive():
326
+ surface.blit(item.image, item.rect)
188
327
  if survivors:
189
328
  for survivor in survivors:
190
329
  if survivor.alive():
@@ -218,7 +357,6 @@ def draw_level_overview(
218
357
 
219
358
  def _get_fog_scale(
220
359
  assets: RenderAssets,
221
- stage: Stage | None,
222
360
  flashlight_count: int,
223
361
  ) -> float:
224
362
  """Return current fog scale factoring in flashlight bonus."""
@@ -226,8 +364,9 @@ def _get_fog_scale(
226
364
  flashlight_count = max(0, int(flashlight_count))
227
365
  if flashlight_count <= 0:
228
366
  return scale
229
- bonus_step = max(0.0, assets.flashlight_bonus_step)
230
- return scale + bonus_step * flashlight_count
367
+ if flashlight_count == 1:
368
+ return max(scale, FLASHLIGHT_FOG_SCALE_ONE)
369
+ return max(scale, FLASHLIGHT_FOG_SCALE_TWO)
231
370
 
232
371
 
233
372
  def _max_flashlight_pickups() -> int:
@@ -247,7 +386,7 @@ class FogProfile(Enum):
247
386
 
248
387
  def _scale(self, assets: RenderAssets, stage: Stage | None) -> float:
249
388
  count = max(0, min(self.flashlight_count, _max_flashlight_pickups()))
250
- return _get_fog_scale(assets, stage, count)
389
+ return _get_fog_scale(assets, count)
251
390
 
252
391
  @staticmethod
253
392
  def _from_flashlight_count(count: int) -> "FogProfile":
@@ -280,19 +419,21 @@ def _get_hatch_pattern(
280
419
  fog_data: dict[str, Any],
281
420
  thickness: int,
282
421
  *,
283
- pixel_scale: int = 1,
284
422
  color: tuple[int, int, int, int] | None = None,
285
423
  ) -> surface.Surface:
286
- """Return cached ordered-dither tile surface (Bayer-style, optionally chunky)."""
424
+ """Return cached dot hatch tile surface (Bayer-ordered, optionally chunky)."""
287
425
  cache = fog_data.setdefault("hatch_patterns", {})
288
- pixel_scale = max(1, pixel_scale)
289
- key = (thickness, pixel_scale, color)
426
+ key = (thickness, color)
290
427
  if key in cache:
291
428
  return cache[key]
292
429
 
293
- spacing = 20
430
+ spacing = 4
431
+ oversample = 3
294
432
  density = max(1, min(thickness, 16))
295
- pattern = pygame.Surface((spacing, spacing), pygame.SRCALPHA)
433
+ pattern_size = spacing * 8
434
+ hi_spacing = spacing * oversample
435
+ hi_pattern_size = pattern_size * oversample
436
+ pattern = pygame.Surface((hi_pattern_size, hi_pattern_size), pygame.SRCALPHA)
296
437
 
297
438
  # 8x8 Bayer matrix values 0..63 for ordered dithering
298
439
  bayer = [
@@ -306,14 +447,20 @@ def _get_hatch_pattern(
306
447
  [63, 31, 55, 23, 61, 29, 53, 21],
307
448
  ]
308
449
  threshold = int((density / 16) * 64)
309
- for y in range(spacing):
310
- for x in range(spacing):
311
- if bayer[y % 8][x % 8] < threshold:
312
- pattern.set_at((x, y), color or (0, 0, 0, 255))
450
+ dot_radius = max(
451
+ 1,
452
+ min(hi_spacing, int(math.ceil((density / 16) * hi_spacing))),
453
+ )
454
+ dot_color = color or (0, 0, 0, 255)
455
+ for grid_y in range(8):
456
+ for grid_x in range(8):
457
+ if bayer[grid_y][grid_x] < threshold:
458
+ cx = grid_x * hi_spacing + hi_spacing // 2
459
+ cy = grid_y * hi_spacing + hi_spacing // 2
460
+ pygame.draw.circle(pattern, dot_color, (cx, cy), dot_radius)
313
461
 
314
- if pixel_scale > 1:
315
- scaled_size = (spacing * pixel_scale, spacing * pixel_scale)
316
- pattern = pygame.transform.scale(pattern, scaled_size)
462
+ if oversample > 1:
463
+ pattern = pygame.transform.smoothscale(pattern, (pattern_size, pattern_size))
317
464
 
318
465
  cache[key] = pattern
319
466
  return pattern
@@ -354,7 +501,6 @@ def _get_fog_overlay_surfaces(
354
501
  pattern = _get_hatch_pattern(
355
502
  fog_data,
356
503
  ring.thickness,
357
- pixel_scale=assets.fog_hatch_pixel_scale,
358
504
  color=base_color,
359
505
  )
360
506
  p_w, p_h = pattern.get_size()
@@ -390,7 +536,7 @@ def _build_flashlight_fade_surface(
390
536
  *,
391
537
  start_ratio: float = 0.2,
392
538
  max_alpha: int = 220,
393
- outer_extension: int = 50,
539
+ outer_extension: int = 30,
394
540
  ) -> surface.Surface:
395
541
  """Return a radial gradient so flashlight edges softly darken again."""
396
542
 
@@ -579,6 +725,7 @@ def _draw_status_bar(
579
725
  debug_mode: bool = False,
580
726
  zombie_group: sprite.Group | None = None,
581
727
  falling_spawn_carry: int | None = None,
728
+ fps: float | None = None,
582
729
  ) -> None:
583
730
  """Render a compact status bar with current config flags and stage info."""
584
731
  bar_rect = pygame.Rect(
@@ -597,7 +744,11 @@ def _draw_status_bar(
597
744
  steel_on = config.get("steel_beams", {}).get("enabled", False)
598
745
  if stage:
599
746
  # Keep the label compact for the status bar
600
- stage_label = f"#{stage.id[-1]}" if stage.id.startswith("stage") else stage.id
747
+ if stage.id.startswith("stage"):
748
+ stage_suffix = stage.id.removeprefix("stage")
749
+ stage_label = f"#{stage_suffix}" if stage_suffix else stage.id
750
+ else:
751
+ stage_label = stage.id
601
752
  else:
602
753
  stage_label = "#1"
603
754
 
@@ -620,6 +771,8 @@ def _draw_status_bar(
620
771
  parts.append(f"Z:{total} N:{normal} T:{tracker} W:{wall}")
621
772
  if falling_spawn_carry is not None:
622
773
  parts.append(f"C:{max(0, falling_spawn_carry)}")
774
+ if fps is not None:
775
+ parts.append(f"FPS:{fps:.1f}")
623
776
 
624
777
  status_text = " | ".join(parts)
625
778
  color = LIGHT_GRAY
@@ -637,6 +790,14 @@ def _draw_status_bar(
637
790
  right=bar_rect.right - 12, centery=bar_rect.centery
638
791
  )
639
792
  screen.blit(seed_surface, seed_rect)
793
+ if debug_mode and fps is not None:
794
+ fps_text = f"FPS:{fps:.1f}"
795
+ fps_surface = font.render(fps_text, False, LIGHT_GRAY)
796
+ fps_rect = fps_surface.get_rect(
797
+ left=12,
798
+ bottom=max(2, bar_rect.top - 4),
799
+ )
800
+ screen.blit(fps_surface, fps_rect)
640
801
  except pygame.error as e:
641
802
  print(f"Error rendering status bar: {e}")
642
803
 
@@ -723,8 +884,18 @@ def _draw_play_area(
723
884
  return xs, ys, xe, ye, outside_cells
724
885
 
725
886
 
887
+ def abs_clip(value: float, min_v: float, max_v: float) -> float:
888
+ value_sign = 1.0 if value >= 0.0 else -1.0
889
+ value = abs(value)
890
+ if value < min_v:
891
+ value = min_v
892
+ elif value > max_v:
893
+ value = max_v
894
+ return value_sign * value
895
+
896
+
726
897
  def _draw_wall_shadows(
727
- screen: surface.Surface,
898
+ shadow_layer: surface.Surface,
728
899
  camera: Camera,
729
900
  *,
730
901
  wall_cells: set[tuple[int, int]],
@@ -732,10 +903,10 @@ def _draw_wall_shadows(
732
903
  outer_wall_cells: set[tuple[int, int]] | None,
733
904
  cell_size: int,
734
905
  light_source_pos: tuple[int, int] | None,
735
- alpha: int = 54,
736
- ) -> None:
906
+ alpha: int = 68,
907
+ ) -> bool:
737
908
  if not wall_cells or cell_size <= 0 or light_source_pos is None:
738
- return
909
+ return False
739
910
  inner_wall_cells = set(wall_cells)
740
911
  if outer_wall_cells:
741
912
  inner_wall_cells.difference_update(outer_wall_cells)
@@ -746,41 +917,153 @@ def _draw_wall_shadows(
746
917
  cell_y = int(wall.rect.centery // cell_size)
747
918
  inner_wall_cells.add((cell_x, cell_y))
748
919
  if not inner_wall_cells:
749
- return
920
+ return False
750
921
  base_shadow_size = max(cell_size + 2, int(cell_size * 1.35))
751
922
  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()
923
+ shadow_surface = _get_shadow_tile_surface(
924
+ shadow_size,
925
+ alpha,
926
+ edge_softness=0.12,
927
+ )
928
+ screen_rect = shadow_layer.get_rect()
756
929
  px, py = light_source_pos
757
- offset = max(2, int(cell_size * 0.3 * (shadow_size / cell_size) * 1.2))
758
930
  drew = False
931
+ clip_max = shadow_size * 0.25
759
932
  for cell_x, cell_y in inner_wall_cells:
760
933
  world_x = cell_x * cell_size
761
934
  world_y = cell_y * cell_size
935
+ wall_rect = pygame.Rect(world_x, world_y, cell_size, cell_size)
936
+ wall_screen_rect = camera.apply_rect(wall_rect)
937
+ if not wall_screen_rect.colliderect(screen_rect):
938
+ continue
762
939
  center_x = world_x + cell_size / 2
763
940
  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
941
+ dx = (center_x - px) * 0.5
942
+ dy = (center_y - py) * 0.5
943
+ dx = int(abs_clip(dx, 0, clip_max))
944
+ dy = int(abs_clip(dy, 0, clip_max))
771
945
  shadow_rect = pygame.Rect(0, 0, shadow_size, shadow_size)
772
946
  shadow_rect.center = (
773
- int(center_x + nx * offset),
774
- int(center_y + ny * offset),
947
+ int(center_x + dx),
948
+ int(center_y + dy),
775
949
  )
776
950
  shadow_screen_rect = camera.apply_rect(shadow_rect)
777
951
  if not shadow_screen_rect.colliderect(screen_rect):
778
952
  continue
779
- shadow_layer.blit(shadow_surface, shadow_screen_rect.topleft)
953
+ shadow_layer.blit(
954
+ shadow_surface,
955
+ shadow_screen_rect.topleft,
956
+ special_flags=pygame.BLEND_RGBA_MAX,
957
+ )
780
958
  drew = True
781
- if drew:
782
- shadow_layer.set_alpha(alpha)
783
- screen.blit(shadow_layer, (0, 0))
959
+ return drew
960
+
961
+
962
+ def _draw_entity_shadows(
963
+ shadow_layer: surface.Surface,
964
+ camera: Camera,
965
+ all_sprites: sprite.LayeredUpdates,
966
+ *,
967
+ light_source_pos: tuple[int, int] | None,
968
+ exclude_car: Car | None,
969
+ shadow_radius: int = int(ZOMBIE_RADIUS * ENTITY_SHADOW_RADIUS_MULT),
970
+ alpha: int = ENTITY_SHADOW_ALPHA,
971
+ ) -> bool:
972
+ if light_source_pos is None or shadow_radius <= 0:
973
+ return False
974
+ shadow_surface = _get_shadow_circle_surface(
975
+ shadow_radius,
976
+ alpha,
977
+ edge_softness=ENTITY_SHADOW_EDGE_SOFTNESS,
978
+ )
979
+ screen_rect = shadow_layer.get_rect()
980
+ px, py = light_source_pos
981
+ offset_dist = max(1.0, shadow_radius * 0.6)
982
+ drew = False
983
+ for entity in all_sprites:
984
+ if not entity.alive():
985
+ continue
986
+ if isinstance(entity, Player):
987
+ continue
988
+ if isinstance(entity, Car):
989
+ if exclude_car is not None and entity is exclude_car:
990
+ continue
991
+ if not isinstance(entity, (Zombie, Survivor, Car)):
992
+ continue
993
+ cx, cy = entity.rect.center
994
+ dx = cx - px
995
+ dy = cy - py
996
+ dist = math.hypot(dx, dy)
997
+ if dist > 0.001:
998
+ scale = offset_dist / dist
999
+ offset_x = dx * scale
1000
+ offset_y = dy * scale
1001
+ else:
1002
+ offset_x = 0.0
1003
+ offset_y = 0.0
1004
+ shadow_rect = shadow_surface.get_rect(
1005
+ center=(int(cx + offset_x), int(cy + offset_y))
1006
+ )
1007
+ shadow_screen_rect = camera.apply_rect(shadow_rect)
1008
+ if not shadow_screen_rect.colliderect(screen_rect):
1009
+ continue
1010
+ shadow_layer.blit(
1011
+ shadow_surface,
1012
+ shadow_screen_rect.topleft,
1013
+ special_flags=pygame.BLEND_RGBA_MAX,
1014
+ )
1015
+ drew = True
1016
+ return drew
1017
+
1018
+
1019
+ def _draw_single_entity_shadow(
1020
+ shadow_layer: surface.Surface,
1021
+ camera: Camera,
1022
+ *,
1023
+ entity: pygame.sprite.Sprite | None,
1024
+ light_source_pos: tuple[int, int] | None,
1025
+ shadow_radius: int,
1026
+ alpha: int,
1027
+ edge_softness: float = ENTITY_SHADOW_EDGE_SOFTNESS,
1028
+ ) -> bool:
1029
+ if (
1030
+ entity is None
1031
+ or not entity.alive()
1032
+ or light_source_pos is None
1033
+ or shadow_radius <= 0
1034
+ ):
1035
+ return False
1036
+ shadow_surface = _get_shadow_circle_surface(
1037
+ shadow_radius,
1038
+ alpha,
1039
+ edge_softness=edge_softness,
1040
+ )
1041
+ screen_rect = shadow_layer.get_rect()
1042
+ px, py = light_source_pos
1043
+ cx, cy = entity.rect.center
1044
+ dx = cx - px
1045
+ dy = cy - py
1046
+ dist = math.hypot(dx, dy)
1047
+ offset_dist = max(1.0, shadow_radius * 0.6)
1048
+ if dist > 0.001:
1049
+ scale = offset_dist / dist
1050
+ offset_x = dx * scale
1051
+ offset_y = dy * scale
1052
+ else:
1053
+ offset_x = 0.0
1054
+ offset_y = 0.0
1055
+ shadow_rect = shadow_surface.get_rect(
1056
+ center=(int(cx + offset_x), int(cy + offset_y))
1057
+ )
1058
+ shadow_screen_rect = camera.apply_rect(shadow_rect)
1059
+ if not shadow_screen_rect.colliderect(screen_rect):
1060
+ return False
1061
+ shadow_layer.blit(
1062
+ shadow_surface,
1063
+ shadow_screen_rect.topleft,
1064
+ special_flags=pygame.BLEND_RGBA_MAX,
1065
+ )
1066
+ return True
784
1067
 
785
1068
 
786
1069
  def _draw_footprints(
@@ -869,11 +1152,7 @@ def _draw_hint_indicator(
869
1152
  ) -> None:
870
1153
  if not hint_target:
871
1154
  return
872
- current_fov_scale = _get_fog_scale(
873
- assets,
874
- stage,
875
- flashlight_count,
876
- )
1155
+ current_fov_scale = _get_fog_scale(assets, flashlight_count)
877
1156
  hint_ring_radius = assets.fov_radius * 0.5 * current_fov_scale
878
1157
  _draw_hint_arrow(
879
1158
  screen,
@@ -958,6 +1237,35 @@ def _draw_objective(lines: list[str], *, screen: surface.Surface) -> None:
958
1237
  print(f"Error rendering objective: {e}")
959
1238
 
960
1239
 
1240
+ def _draw_inventory_icons(
1241
+ screen: surface.Surface,
1242
+ assets: RenderAssets,
1243
+ *,
1244
+ has_fuel: bool,
1245
+ flashlight_count: int,
1246
+ shoes_count: int,
1247
+ ) -> None:
1248
+ icons: list[surface.Surface] = []
1249
+ if has_fuel:
1250
+ icons.append(_get_hud_icon("fuel"))
1251
+ for _ in range(max(0, int(flashlight_count))):
1252
+ icons.append(_get_hud_icon("flashlight"))
1253
+ for _ in range(max(0, int(shoes_count))):
1254
+ icons.append(_get_hud_icon("shoes"))
1255
+ if not icons:
1256
+ return
1257
+ spacing = 3
1258
+ padding = 8
1259
+ total_width = sum(icon.get_width() for icon in icons)
1260
+ total_width += spacing * max(0, len(icons) - 1)
1261
+ start_x = assets.screen_width - padding - total_width
1262
+ y = 8
1263
+ x = max(padding, start_x)
1264
+ for icon in icons:
1265
+ screen.blit(icon, (x, y))
1266
+ x += icon.get_width() + spacing
1267
+
1268
+
961
1269
  def _draw_endurance_timer(
962
1270
  screen: surface.Surface,
963
1271
  assets: RenderAssets,
@@ -1146,6 +1454,7 @@ def draw(
1146
1454
  hint_color: tuple[int, int, int] | None = None,
1147
1455
  do_flip: bool = True,
1148
1456
  present_fn: Callable[[surface.Surface], None] | None = None,
1457
+ fps: float | None = None,
1149
1458
  ) -> None:
1150
1459
  hint_color = hint_color or YELLOW
1151
1460
  state = game_data.state
@@ -1162,6 +1471,7 @@ def draw(
1162
1471
  footprints = state.footprints
1163
1472
  has_fuel = state.has_fuel
1164
1473
  flashlight_count = state.flashlight_count
1474
+ shoes_count = state.shoes_count
1165
1475
  elapsed_play_ms = state.elapsed_play_ms
1166
1476
  fuel_message_until = state.fuel_message_until
1167
1477
  buddy_onboard = state.buddy_onboard
@@ -1187,19 +1497,52 @@ def draw(
1187
1497
  outside_rects,
1188
1498
  game_data.layout.fall_spawn_cells,
1189
1499
  )
1190
- _draw_wall_shadows(
1191
- screen,
1500
+ shadow_layer = _get_shadow_layer(screen.get_size())
1501
+ shadow_layer.fill((0, 0, 0, 0))
1502
+ drew_shadow = _draw_wall_shadows(
1503
+ shadow_layer,
1192
1504
  camera,
1193
1505
  wall_cells=game_data.layout.wall_cells,
1194
1506
  wall_group=game_data.groups.wall_group,
1195
1507
  outer_wall_cells=game_data.layout.outer_wall_cells,
1196
1508
  cell_size=game_data.cell_size,
1197
1509
  light_source_pos=(
1198
- None if (stage and stage.endurance_stage and state.dawn_ready) else fov_target.rect.center
1510
+ None
1511
+ if (stage and stage.endurance_stage and state.dawn_ready)
1512
+ else fov_target.rect.center
1199
1513
  )
1200
1514
  if fov_target
1201
1515
  else None,
1202
1516
  )
1517
+ drew_shadow |= _draw_entity_shadows(
1518
+ shadow_layer,
1519
+ camera,
1520
+ all_sprites,
1521
+ light_source_pos=fov_target.rect.center if fov_target else None,
1522
+ exclude_car=active_car if player.in_car else None,
1523
+ )
1524
+ player_shadow_alpha = max(1, int(ENTITY_SHADOW_ALPHA * PLAYER_SHADOW_ALPHA_MULT))
1525
+ player_shadow_radius = int(ZOMBIE_RADIUS * PLAYER_SHADOW_RADIUS_MULT)
1526
+ if player.in_car:
1527
+ drew_shadow |= _draw_single_entity_shadow(
1528
+ shadow_layer,
1529
+ camera,
1530
+ entity=active_car,
1531
+ light_source_pos=fov_target.rect.center if fov_target else None,
1532
+ shadow_radius=player_shadow_radius,
1533
+ alpha=player_shadow_alpha,
1534
+ )
1535
+ else:
1536
+ drew_shadow |= _draw_single_entity_shadow(
1537
+ shadow_layer,
1538
+ camera,
1539
+ entity=player,
1540
+ light_source_pos=fov_target.rect.center if fov_target else None,
1541
+ shadow_radius=player_shadow_radius,
1542
+ alpha=player_shadow_alpha,
1543
+ )
1544
+ if drew_shadow:
1545
+ screen.blit(shadow_layer, (0, 0))
1203
1546
  _draw_footprints(
1204
1547
  screen,
1205
1548
  camera,
@@ -1263,6 +1606,13 @@ def draw(
1263
1606
  )
1264
1607
  if objective_lines:
1265
1608
  _draw_objective(objective_lines, screen=screen)
1609
+ _draw_inventory_icons(
1610
+ screen,
1611
+ assets,
1612
+ has_fuel=has_fuel,
1613
+ flashlight_count=flashlight_count,
1614
+ shoes_count=shoes_count,
1615
+ )
1266
1616
  _draw_survivor_messages(screen, assets, survivor_messages)
1267
1617
  _draw_endurance_timer(screen, assets, stage=stage, state=state)
1268
1618
  _draw_time_accel_indicator(screen, assets, stage=stage, state=state)
@@ -1275,6 +1625,7 @@ def draw(
1275
1625
  debug_mode=state.debug_mode,
1276
1626
  zombie_group=zombie_group,
1277
1627
  falling_spawn_carry=state.falling_spawn_carry,
1628
+ fps=fps,
1278
1629
  )
1279
1630
  if do_flip:
1280
1631
  if present_fn: