zombie-escape 1.10.1__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,11 +46,25 @@ 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,
44
68
  SHADOW_MIN_RATIO,
45
69
  SHADOW_OVERSAMPLE,
46
70
  SHADOW_RADIUS_RATIO,
@@ -49,6 +73,10 @@ from .render_constants import (
49
73
 
50
74
  _SHADOW_TILE_CACHE: dict[tuple[int, int, float], surface.Surface] = {}
51
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
52
80
 
53
81
 
54
82
  def _get_shadow_tile_surface(
@@ -113,6 +141,98 @@ def _get_shadow_layer(size: tuple[int, int]) -> surface.Surface:
113
141
  return layer
114
142
 
115
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
+
116
236
  def show_message(
117
237
  screen: surface.Surface,
118
238
  text: str,
@@ -149,7 +269,7 @@ def draw_level_overview(
149
269
  *,
150
270
  fuel: FuelCan | None = None,
151
271
  flashlights: list[Flashlight] | None = None,
152
- stage: Stage | None = None,
272
+ shoes: list[Shoes] | None = None,
153
273
  buddies: list[Survivor] | None = None,
154
274
  survivors: list[Survivor] | None = None,
155
275
  palette_key: str | None = None,
@@ -195,11 +315,15 @@ def draw_level_overview(
195
315
  for flashlight in flashlights:
196
316
  if flashlight.alive():
197
317
  pygame.draw.rect(
198
- surface, (240, 230, 150), flashlight.rect, border_radius=2
318
+ surface, YELLOW, flashlight.rect, border_radius=2
199
319
  )
200
320
  pygame.draw.rect(
201
321
  surface, BLACK, flashlight.rect, width=2, border_radius=2
202
322
  )
323
+ if shoes:
324
+ for item in shoes:
325
+ if item.alive():
326
+ surface.blit(item.image, item.rect)
203
327
  if survivors:
204
328
  for survivor in survivors:
205
329
  if survivor.alive():
@@ -233,7 +357,6 @@ def draw_level_overview(
233
357
 
234
358
  def _get_fog_scale(
235
359
  assets: RenderAssets,
236
- stage: Stage | None,
237
360
  flashlight_count: int,
238
361
  ) -> float:
239
362
  """Return current fog scale factoring in flashlight bonus."""
@@ -241,8 +364,9 @@ def _get_fog_scale(
241
364
  flashlight_count = max(0, int(flashlight_count))
242
365
  if flashlight_count <= 0:
243
366
  return scale
244
- bonus_step = max(0.0, assets.flashlight_bonus_step)
245
- 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)
246
370
 
247
371
 
248
372
  def _max_flashlight_pickups() -> int:
@@ -262,7 +386,7 @@ class FogProfile(Enum):
262
386
 
263
387
  def _scale(self, assets: RenderAssets, stage: Stage | None) -> float:
264
388
  count = max(0, min(self.flashlight_count, _max_flashlight_pickups()))
265
- return _get_fog_scale(assets, stage, count)
389
+ return _get_fog_scale(assets, count)
266
390
 
267
391
  @staticmethod
268
392
  def _from_flashlight_count(count: int) -> "FogProfile":
@@ -601,6 +725,7 @@ def _draw_status_bar(
601
725
  debug_mode: bool = False,
602
726
  zombie_group: sprite.Group | None = None,
603
727
  falling_spawn_carry: int | None = None,
728
+ fps: float | None = None,
604
729
  ) -> None:
605
730
  """Render a compact status bar with current config flags and stage info."""
606
731
  bar_rect = pygame.Rect(
@@ -646,6 +771,8 @@ def _draw_status_bar(
646
771
  parts.append(f"Z:{total} N:{normal} T:{tracker} W:{wall}")
647
772
  if falling_spawn_carry is not None:
648
773
  parts.append(f"C:{max(0, falling_spawn_carry)}")
774
+ if fps is not None:
775
+ parts.append(f"FPS:{fps:.1f}")
649
776
 
650
777
  status_text = " | ".join(parts)
651
778
  color = LIGHT_GRAY
@@ -663,6 +790,14 @@ def _draw_status_bar(
663
790
  right=bar_rect.right - 12, centery=bar_rect.centery
664
791
  )
665
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)
666
801
  except pygame.error as e:
667
802
  print(f"Error rendering status bar: {e}")
668
803
 
@@ -760,7 +895,7 @@ def abs_clip(value: float, min_v: float, max_v: float) -> float:
760
895
 
761
896
 
762
897
  def _draw_wall_shadows(
763
- screen: surface.Surface,
898
+ shadow_layer: surface.Surface,
764
899
  camera: Camera,
765
900
  *,
766
901
  wall_cells: set[tuple[int, int]],
@@ -769,9 +904,9 @@ def _draw_wall_shadows(
769
904
  cell_size: int,
770
905
  light_source_pos: tuple[int, int] | None,
771
906
  alpha: int = 68,
772
- ) -> None:
907
+ ) -> bool:
773
908
  if not wall_cells or cell_size <= 0 or light_source_pos is None:
774
- return
909
+ return False
775
910
  inner_wall_cells = set(wall_cells)
776
911
  if outer_wall_cells:
777
912
  inner_wall_cells.difference_update(outer_wall_cells)
@@ -782,13 +917,15 @@ def _draw_wall_shadows(
782
917
  cell_y = int(wall.rect.centery // cell_size)
783
918
  inner_wall_cells.add((cell_x, cell_y))
784
919
  if not inner_wall_cells:
785
- return
920
+ return False
786
921
  base_shadow_size = max(cell_size + 2, int(cell_size * 1.35))
787
922
  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()
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()
792
929
  px, py = light_source_pos
793
930
  drew = False
794
931
  clip_max = shadow_size * 0.25
@@ -813,11 +950,120 @@ def _draw_wall_shadows(
813
950
  shadow_screen_rect = camera.apply_rect(shadow_rect)
814
951
  if not shadow_screen_rect.colliderect(screen_rect):
815
952
  continue
816
- 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
+ )
817
958
  drew = True
818
- if drew:
819
- shadow_layer.set_alpha(alpha)
820
- 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
821
1067
 
822
1068
 
823
1069
  def _draw_footprints(
@@ -906,11 +1152,7 @@ def _draw_hint_indicator(
906
1152
  ) -> None:
907
1153
  if not hint_target:
908
1154
  return
909
- current_fov_scale = _get_fog_scale(
910
- assets,
911
- stage,
912
- flashlight_count,
913
- )
1155
+ current_fov_scale = _get_fog_scale(assets, flashlight_count)
914
1156
  hint_ring_radius = assets.fov_radius * 0.5 * current_fov_scale
915
1157
  _draw_hint_arrow(
916
1158
  screen,
@@ -995,6 +1237,35 @@ def _draw_objective(lines: list[str], *, screen: surface.Surface) -> None:
995
1237
  print(f"Error rendering objective: {e}")
996
1238
 
997
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
+
998
1269
  def _draw_endurance_timer(
999
1270
  screen: surface.Surface,
1000
1271
  assets: RenderAssets,
@@ -1183,6 +1454,7 @@ def draw(
1183
1454
  hint_color: tuple[int, int, int] | None = None,
1184
1455
  do_flip: bool = True,
1185
1456
  present_fn: Callable[[surface.Surface], None] | None = None,
1457
+ fps: float | None = None,
1186
1458
  ) -> None:
1187
1459
  hint_color = hint_color or YELLOW
1188
1460
  state = game_data.state
@@ -1199,6 +1471,7 @@ def draw(
1199
1471
  footprints = state.footprints
1200
1472
  has_fuel = state.has_fuel
1201
1473
  flashlight_count = state.flashlight_count
1474
+ shoes_count = state.shoes_count
1202
1475
  elapsed_play_ms = state.elapsed_play_ms
1203
1476
  fuel_message_until = state.fuel_message_until
1204
1477
  buddy_onboard = state.buddy_onboard
@@ -1224,8 +1497,10 @@ def draw(
1224
1497
  outside_rects,
1225
1498
  game_data.layout.fall_spawn_cells,
1226
1499
  )
1227
- _draw_wall_shadows(
1228
- 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,
1229
1504
  camera,
1230
1505
  wall_cells=game_data.layout.wall_cells,
1231
1506
  wall_group=game_data.groups.wall_group,
@@ -1239,6 +1514,35 @@ def draw(
1239
1514
  if fov_target
1240
1515
  else None,
1241
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))
1242
1546
  _draw_footprints(
1243
1547
  screen,
1244
1548
  camera,
@@ -1302,6 +1606,13 @@ def draw(
1302
1606
  )
1303
1607
  if objective_lines:
1304
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
+ )
1305
1616
  _draw_survivor_messages(screen, assets, survivor_messages)
1306
1617
  _draw_endurance_timer(screen, assets, stage=stage, state=state)
1307
1618
  _draw_time_accel_indicator(screen, assets, stage=stage, state=state)
@@ -1314,6 +1625,7 @@ def draw(
1314
1625
  debug_mode=state.debug_mode,
1315
1626
  zombie_group=zombie_group,
1316
1627
  falling_spawn_carry=state.falling_spawn_carry,
1628
+ fps=fps,
1317
1629
  )
1318
1630
  if do_flip:
1319
1631
  if present_fn: