zombie-escape 1.13.1__py3-none-any.whl → 1.14.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/colors.py +7 -21
  3. zombie_escape/entities.py +100 -191
  4. zombie_escape/export_images.py +39 -33
  5. zombie_escape/gameplay/ambient.py +2 -6
  6. zombie_escape/gameplay/footprints.py +8 -11
  7. zombie_escape/gameplay/interactions.py +17 -58
  8. zombie_escape/gameplay/layout.py +20 -46
  9. zombie_escape/gameplay/movement.py +7 -21
  10. zombie_escape/gameplay/spawn.py +12 -40
  11. zombie_escape/gameplay/state.py +1 -0
  12. zombie_escape/gameplay/survivors.py +5 -16
  13. zombie_escape/gameplay/utils.py +4 -13
  14. zombie_escape/input_utils.py +8 -31
  15. zombie_escape/level_blueprints.py +112 -69
  16. zombie_escape/level_constants.py +8 -0
  17. zombie_escape/locales/ui.en.json +12 -0
  18. zombie_escape/locales/ui.ja.json +12 -0
  19. zombie_escape/localization.py +3 -11
  20. zombie_escape/models.py +26 -9
  21. zombie_escape/render/__init__.py +30 -0
  22. zombie_escape/render/core.py +992 -0
  23. zombie_escape/render/hud.py +444 -0
  24. zombie_escape/render/overview.py +218 -0
  25. zombie_escape/render/shadows.py +343 -0
  26. zombie_escape/render_assets.py +11 -33
  27. zombie_escape/rng.py +4 -8
  28. zombie_escape/screens/__init__.py +14 -30
  29. zombie_escape/screens/game_over.py +43 -15
  30. zombie_escape/screens/gameplay.py +41 -104
  31. zombie_escape/screens/settings.py +19 -104
  32. zombie_escape/screens/title.py +36 -176
  33. zombie_escape/stage_constants.py +192 -67
  34. zombie_escape/zombie_escape.py +1 -1
  35. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/METADATA +100 -39
  36. zombie_escape-1.14.4.dist-info/RECORD +53 -0
  37. zombie_escape/render.py +0 -1746
  38. zombie_escape-1.13.1.dist-info/RECORD +0 -49
  39. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/WHEEL +0 -0
  40. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/entry_points.txt +0 -0
  41. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/render.py DELETED
@@ -1,1746 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import math
4
- from enum import Enum
5
- from typing import Any, Callable
6
-
7
- import pygame
8
- import pygame.surfarray as pg_surfarray # type: ignore
9
- from pygame import sprite, surface
10
-
11
- from .colors import (
12
- BLACK,
13
- BLUE,
14
- FOOTPRINT_COLOR,
15
- LIGHT_GRAY,
16
- ORANGE,
17
- YELLOW,
18
- get_environment_palette,
19
- )
20
- from .entities import (
21
- Camera,
22
- Car,
23
- Flashlight,
24
- FuelCan,
25
- Player,
26
- Shoes,
27
- SteelBeam,
28
- Survivor,
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
- INTERNAL_WALL_BEVEL_DEPTH,
38
- JUMP_SHADOW_OFFSET,
39
- SHOES_HEIGHT,
40
- SHOES_WIDTH,
41
- ZOMBIE_RADIUS,
42
- )
43
- from .font_utils import load_font
44
- from .gameplay_constants import (
45
- DEFAULT_FLASHLIGHT_SPAWN_COUNT,
46
- SURVIVAL_FAKE_CLOCK_RATIO,
47
- )
48
- from .localization import get_font_settings
49
- from .localization import translate as tr
50
- from .models import DustRing, FallingZombie, Footprint, GameData, Stage
51
- from .render_assets import (
52
- RenderAssets,
53
- build_flashlight_surface,
54
- build_fuel_can_surface,
55
- build_shoes_surface,
56
- resolve_steel_beam_colors,
57
- resolve_wall_colors,
58
- )
59
- from .render_constants import (
60
- ENTITY_SHADOW_ALPHA,
61
- ENTITY_SHADOW_EDGE_SOFTNESS,
62
- ENTITY_SHADOW_RADIUS_MULT,
63
- FALLING_DUST_COLOR,
64
- FALLING_WHIRLWIND_COLOR,
65
- FALLING_ZOMBIE_COLOR,
66
- FLASHLIGHT_FOG_SCALE_ONE,
67
- FLASHLIGHT_FOG_SCALE_TWO,
68
- PITFALL_ABYSS_COLOR,
69
- PITFALL_EDGE_DEPTH_OFFSET,
70
- PITFALL_EDGE_METAL_COLOR,
71
- PITFALL_EDGE_STRIPE_COLOR,
72
- PITFALL_EDGE_STRIPE_SPACING,
73
- PLAYER_SHADOW_ALPHA_MULT,
74
- PLAYER_SHADOW_RADIUS_MULT,
75
- SHADOW_MIN_RATIO,
76
- SHADOW_OVERSAMPLE,
77
- SHADOW_RADIUS_RATIO,
78
- SHADOW_STEPS,
79
- )
80
-
81
- _SHADOW_TILE_CACHE: dict[tuple[int, int, float], surface.Surface] = {}
82
- _SHADOW_LAYER_CACHE: dict[tuple[int, int], surface.Surface] = {}
83
- _SHADOW_CIRCLE_CACHE: dict[tuple[int, int, float], surface.Surface] = {}
84
- _HUD_ICON_CACHE: dict[str, surface.Surface] = {}
85
-
86
- HUD_ICON_SIZE = 12
87
-
88
-
89
- def _get_shadow_tile_surface(
90
- cell_size: int,
91
- alpha: int,
92
- *,
93
- edge_softness: float = 0.35,
94
- ) -> surface.Surface:
95
- key = (max(1, cell_size), max(0, min(255, alpha)), edge_softness)
96
- if key in _SHADOW_TILE_CACHE:
97
- return _SHADOW_TILE_CACHE[key]
98
- size = key[0]
99
- oversample = SHADOW_OVERSAMPLE
100
- render_size = size * oversample
101
- render_surf = pygame.Surface((render_size, render_size), pygame.SRCALPHA)
102
- base_alpha = key[1]
103
- if edge_softness <= 0:
104
- render_surf.fill((0, 0, 0, base_alpha))
105
- if oversample > 1:
106
- surf = pygame.transform.smoothscale(render_surf, (size, size))
107
- else:
108
- surf = render_surf
109
- _SHADOW_TILE_CACHE[key] = surf
110
- return surf
111
-
112
- softness = max(0.0, min(1.0, edge_softness))
113
- fade_band = max(1, int(render_size * softness))
114
- base_radius = max(1, int(render_size * SHADOW_RADIUS_RATIO))
115
-
116
- render_surf.fill((0, 0, 0, 0))
117
- steps = SHADOW_STEPS
118
- min_ratio = SHADOW_MIN_RATIO
119
- for idx in range(steps):
120
- t = idx / (steps - 1) if steps > 1 else 1.0
121
- inset = int(fade_band * t)
122
- rect_size = render_size - inset * 2
123
- if rect_size <= 0:
124
- continue
125
- radius = max(0, base_radius - inset)
126
- layer_alpha = int(base_alpha * (min_ratio + (1.0 - min_ratio) * t))
127
- pygame.draw.rect(
128
- render_surf,
129
- (0, 0, 0, layer_alpha),
130
- pygame.Rect(inset, inset, rect_size, rect_size),
131
- border_radius=radius,
132
- )
133
-
134
- if oversample > 1:
135
- surf = pygame.transform.smoothscale(render_surf, (size, size))
136
- else:
137
- surf = render_surf
138
- _SHADOW_TILE_CACHE[key] = surf
139
- return surf
140
-
141
-
142
- def _get_shadow_layer(size: tuple[int, int]) -> surface.Surface:
143
- key = (max(1, size[0]), max(1, size[1]))
144
- if key in _SHADOW_LAYER_CACHE:
145
- return _SHADOW_LAYER_CACHE[key]
146
- layer = pygame.Surface(key, pygame.SRCALPHA)
147
- _SHADOW_LAYER_CACHE[key] = layer
148
- return layer
149
-
150
-
151
- def _scale_icon_to_box(icon: surface.Surface, size: int) -> surface.Surface:
152
- target_size = max(1, size)
153
- width = max(1, icon.get_width())
154
- height = max(1, icon.get_height())
155
- scale = min(target_size / width, target_size / height)
156
- target_width = max(1, int(width * scale))
157
- target_height = max(1, int(height * scale))
158
- scaled = pygame.transform.smoothscale(icon, (target_width, target_height))
159
- boxed = pygame.Surface((target_size, target_size), pygame.SRCALPHA)
160
- boxed.blit(
161
- scaled,
162
- (
163
- (target_size - target_width) // 2,
164
- (target_size - target_height) // 2,
165
- ),
166
- )
167
- return boxed
168
-
169
-
170
- def _get_hud_icon(kind: str) -> surface.Surface:
171
- cached = _HUD_ICON_CACHE.get(kind)
172
- if cached is not None:
173
- return cached
174
- if kind == "fuel":
175
- icon = build_fuel_can_surface(FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT)
176
- elif kind == "flashlight":
177
- icon = build_flashlight_surface(FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT)
178
- elif kind == "shoes":
179
- icon = build_shoes_surface(SHOES_WIDTH, SHOES_HEIGHT)
180
- else:
181
- icon = pygame.Surface((1, 1), pygame.SRCALPHA)
182
- icon = _scale_icon_to_box(icon, HUD_ICON_SIZE)
183
- _HUD_ICON_CACHE[kind] = icon
184
- return icon
185
-
186
-
187
- def _get_shadow_circle_surface(
188
- radius: int,
189
- alpha: int,
190
- *,
191
- edge_softness: float = 0.12,
192
- ) -> surface.Surface:
193
- key = (max(1, radius), max(0, min(255, alpha)), edge_softness)
194
- if key in _SHADOW_CIRCLE_CACHE:
195
- return _SHADOW_CIRCLE_CACHE[key]
196
- radius = key[0]
197
- oversample = SHADOW_OVERSAMPLE
198
- render_radius = radius * oversample
199
- render_size = render_radius * 2
200
- render_surf = pygame.Surface((render_size, render_size), pygame.SRCALPHA)
201
- base_alpha = key[1]
202
- if edge_softness <= 0:
203
- pygame.draw.circle(
204
- render_surf,
205
- (0, 0, 0, base_alpha),
206
- (render_radius, render_radius),
207
- render_radius,
208
- )
209
- if oversample > 1:
210
- surf = pygame.transform.smoothscale(render_surf, (radius * 2, radius * 2))
211
- else:
212
- surf = render_surf
213
- _SHADOW_CIRCLE_CACHE[key] = surf
214
- return surf
215
-
216
- softness = max(0.0, min(1.0, edge_softness))
217
- fade_band = max(1, int(render_radius * softness))
218
- steps = SHADOW_STEPS
219
- min_ratio = SHADOW_MIN_RATIO
220
- render_surf.fill((0, 0, 0, 0))
221
- for idx in range(steps):
222
- t = idx / (steps - 1) if steps > 1 else 1.0
223
- inset = int(fade_band * t)
224
- circle_radius = render_radius - inset
225
- if circle_radius <= 0:
226
- continue
227
- layer_alpha = int(base_alpha * (min_ratio + (1.0 - min_ratio) * t))
228
- pygame.draw.circle(
229
- render_surf,
230
- (0, 0, 0, layer_alpha),
231
- (render_radius, render_radius),
232
- circle_radius,
233
- )
234
-
235
- if oversample > 1:
236
- surf = pygame.transform.smoothscale(render_surf, (radius * 2, radius * 2))
237
- else:
238
- surf = render_surf
239
- _SHADOW_CIRCLE_CACHE[key] = surf
240
- return surf
241
-
242
-
243
- def show_message(
244
- screen: surface.Surface,
245
- text: str,
246
- size: int,
247
- color: tuple[int, int, int],
248
- position: tuple[int, int],
249
- ) -> None:
250
- try:
251
- font_settings = get_font_settings()
252
- font = load_font(font_settings.resource, font_settings.scaled_size(size))
253
- text_surface = font.render(text, False, color)
254
- text_rect = text_surface.get_rect(center=position)
255
-
256
- # Add a semi-transparent background rectangle for better visibility
257
- bg_padding = 15
258
- bg_rect = text_rect.inflate(bg_padding * 2, bg_padding * 2)
259
- bg_surface = pygame.Surface((bg_rect.width, bg_rect.height), pygame.SRCALPHA)
260
- bg_surface.fill((0, 0, 0, 180)) # Black with 180 alpha (out of 255)
261
- screen.blit(bg_surface, bg_rect.topleft)
262
-
263
- screen.blit(text_surface, text_rect)
264
- except pygame.error as e:
265
- print(f"Error rendering font or surface: {e}")
266
-
267
-
268
- def draw_level_overview(
269
- assets: RenderAssets,
270
- surface: surface.Surface,
271
- wall_group: sprite.Group,
272
- player: Player | None,
273
- car: Car | None,
274
- waiting_cars: list[Car] | None,
275
- footprints: list[Footprint],
276
- *,
277
- fuel: FuelCan | None = None,
278
- flashlights: list[Flashlight] | None = None,
279
- shoes: list[Shoes] | None = None,
280
- buddies: list[Survivor] | None = None,
281
- survivors: list[Survivor] | None = None,
282
- palette_key: str | None = None,
283
- ) -> None:
284
- palette = get_environment_palette(palette_key)
285
- base_floor = palette.floor_primary
286
- dark_floor = tuple(max(0, int(channel * 0.55)) for channel in base_floor)
287
- surface.fill(dark_floor)
288
- for wall in wall_group:
289
- if wall.max_health > 0:
290
- health_ratio = max(0.0, min(1.0, wall.health / wall.max_health))
291
- else:
292
- health_ratio = 0.0
293
- if isinstance(wall, Wall):
294
- fill_color, _ = resolve_wall_colors(
295
- health_ratio=health_ratio,
296
- palette_category=wall.palette_category,
297
- palette=palette,
298
- )
299
- pygame.draw.rect(surface, fill_color, wall.rect)
300
- elif isinstance(wall, SteelBeam):
301
- fill_color, _ = resolve_steel_beam_colors(
302
- health_ratio=health_ratio,
303
- palette=palette,
304
- )
305
- pygame.draw.rect(surface, fill_color, wall.rect)
306
- now = pygame.time.get_ticks()
307
- for fp in footprints:
308
- age = now - fp.time
309
- fade = 1 - (age / assets.footprint_lifetime_ms)
310
- fade = max(assets.footprint_min_fade, fade)
311
- color = tuple(int(c * fade) for c in FOOTPRINT_COLOR)
312
- pygame.draw.circle(
313
- surface,
314
- color,
315
- (int(fp.pos[0]), int(fp.pos[1])),
316
- assets.footprint_overview_radius,
317
- )
318
- if fuel and fuel.alive():
319
- pygame.draw.rect(surface, YELLOW, fuel.rect, border_radius=3)
320
- pygame.draw.rect(surface, BLACK, fuel.rect, width=2, border_radius=3)
321
- if flashlights:
322
- for flashlight in flashlights:
323
- if flashlight.alive():
324
- pygame.draw.rect(surface, YELLOW, flashlight.rect, border_radius=2)
325
- pygame.draw.rect(
326
- surface, BLACK, flashlight.rect, width=2, border_radius=2
327
- )
328
- if shoes:
329
- for item in shoes:
330
- if item.alive():
331
- surface.blit(item.image, item.rect)
332
- if survivors:
333
- for survivor in survivors:
334
- if survivor.alive():
335
- pygame.draw.circle(
336
- surface,
337
- (220, 220, 255),
338
- survivor.rect.center,
339
- assets.player_radius * 2,
340
- )
341
- if player:
342
- pygame.draw.circle(surface, BLUE, player.rect.center, assets.player_radius * 2)
343
- if buddies:
344
- buddy_color = (0, 200, 70)
345
- for buddy in buddies:
346
- if buddy.alive() and not buddy.rescued:
347
- pygame.draw.circle(
348
- surface, buddy_color, buddy.rect.center, assets.player_radius * 2
349
- )
350
- drawn_cars: list[Car] = []
351
- if car and car.alive():
352
- car_rect = car.image.get_rect(center=car.rect.center)
353
- surface.blit(car.image, car_rect)
354
- drawn_cars.append(car)
355
- if waiting_cars:
356
- for parked in waiting_cars:
357
- if not parked.alive() or parked in drawn_cars:
358
- continue
359
- parked_rect = parked.image.get_rect(center=parked.rect.center)
360
- surface.blit(parked.image, parked_rect)
361
-
362
-
363
- def _get_fog_scale(
364
- assets: RenderAssets,
365
- flashlight_count: int,
366
- ) -> float:
367
- """Return current fog scale factoring in flashlight bonus."""
368
- scale = assets.fog_radius_scale
369
- flashlight_count = max(0, int(flashlight_count))
370
- if flashlight_count <= 0:
371
- return scale
372
- if flashlight_count == 1:
373
- return max(scale, FLASHLIGHT_FOG_SCALE_ONE)
374
- return max(scale, FLASHLIGHT_FOG_SCALE_TWO)
375
-
376
-
377
- def _max_flashlight_pickups() -> int:
378
- """Return the maximum flashlight pickups available per stage."""
379
- return max(1, DEFAULT_FLASHLIGHT_SPAWN_COUNT)
380
-
381
-
382
- class FogProfile(Enum):
383
- DARK0 = (0, (0, 0, 0, 255))
384
- DARK1 = (1, (0, 0, 0, 255))
385
- DARK2 = (2, (0, 0, 0, 255))
386
- DAWN = (2, (50, 50, 50, 230))
387
-
388
- def __init__(self, flashlight_count: int, color: tuple[int, int, int, int]) -> None:
389
- self.flashlight_count = flashlight_count
390
- self.color = color
391
-
392
- def _scale(self, assets: RenderAssets, stage: Stage | None) -> float:
393
- count = max(0, min(self.flashlight_count, _max_flashlight_pickups()))
394
- return _get_fog_scale(assets, count)
395
-
396
- @staticmethod
397
- def _from_flashlight_count(count: int) -> "FogProfile":
398
- safe_count = max(0, count)
399
- if safe_count >= 2:
400
- return FogProfile.DARK2
401
- if safe_count == 1:
402
- return FogProfile.DARK1
403
- return FogProfile.DARK0
404
-
405
-
406
- def prewarm_fog_overlays(
407
- fog_data: dict[str, Any],
408
- assets: RenderAssets,
409
- *,
410
- stage: Stage | None = None,
411
- ) -> None:
412
- """Populate fog overlay cache for each reachable flashlight count."""
413
-
414
- for profile in FogProfile:
415
- _get_fog_overlay_surfaces(
416
- fog_data,
417
- assets,
418
- profile,
419
- stage=stage,
420
- )
421
-
422
-
423
- def _get_hatch_pattern(
424
- fog_data: dict[str, Any],
425
- thickness: int,
426
- *,
427
- color: tuple[int, int, int, int] | None = None,
428
- ) -> surface.Surface:
429
- """Return cached dot hatch tile surface (Bayer-ordered, optionally chunky)."""
430
- cache = fog_data.setdefault("hatch_patterns", {})
431
- key = (thickness, color)
432
- if key in cache:
433
- return cache[key]
434
-
435
- spacing = 4
436
- oversample = 3
437
- density = max(1, min(thickness, 16))
438
- pattern_size = spacing * 8
439
- hi_spacing = spacing * oversample
440
- hi_pattern_size = pattern_size * oversample
441
- pattern = pygame.Surface((hi_pattern_size, hi_pattern_size), pygame.SRCALPHA)
442
-
443
- # 8x8 Bayer matrix values 0..63 for ordered dithering
444
- bayer = [
445
- [0, 32, 8, 40, 2, 34, 10, 42],
446
- [48, 16, 56, 24, 50, 18, 58, 26],
447
- [12, 44, 4, 36, 14, 46, 6, 38],
448
- [60, 28, 52, 20, 62, 30, 54, 22],
449
- [3, 35, 11, 43, 1, 33, 9, 41],
450
- [51, 19, 59, 27, 49, 17, 57, 25],
451
- [15, 47, 7, 39, 13, 45, 5, 37],
452
- [63, 31, 55, 23, 61, 29, 53, 21],
453
- ]
454
- threshold = int((density / 16) * 64)
455
- dot_radius = max(
456
- 1,
457
- min(hi_spacing, int(math.ceil((density / 16) * hi_spacing))),
458
- )
459
- dot_color = color or (0, 0, 0, 255)
460
- for grid_y in range(8):
461
- for grid_x in range(8):
462
- if bayer[grid_y][grid_x] < threshold:
463
- cx = grid_x * hi_spacing + hi_spacing // 2
464
- cy = grid_y * hi_spacing + hi_spacing // 2
465
- pygame.draw.circle(pattern, dot_color, (cx, cy), dot_radius)
466
-
467
- if oversample > 1:
468
- pattern = pygame.transform.smoothscale(pattern, (pattern_size, pattern_size))
469
-
470
- cache[key] = pattern
471
- return pattern
472
-
473
-
474
- def _get_fog_overlay_surfaces(
475
- fog_data: dict[str, Any],
476
- assets: RenderAssets,
477
- profile: FogProfile,
478
- *,
479
- stage: Stage | None = None,
480
- ) -> dict[str, Any]:
481
- overlays = fog_data.setdefault("overlays", {})
482
- key = profile
483
- if key in overlays:
484
- return overlays[key]
485
-
486
- scale = profile._scale(assets, stage)
487
- ring_scale = scale
488
- if profile.flashlight_count >= 2:
489
- ring_scale += max(0.0, assets.flashlight_hatch_extra_scale)
490
- max_radius = int(assets.fov_radius * scale)
491
- padding = 32
492
- coverage_width = max(assets.screen_width * 2, max_radius * 2)
493
- coverage_height = max(assets.screen_height * 2, max_radius * 2)
494
- width = coverage_width + padding * 2
495
- height = coverage_height + padding * 2
496
- center = (width // 2, height // 2)
497
-
498
- hard_surface = pygame.Surface((width, height), pygame.SRCALPHA)
499
- base_color = profile.color
500
- hard_surface.fill(base_color)
501
- pygame.draw.circle(hard_surface, (0, 0, 0, 0), center, max_radius)
502
-
503
- ring_surfaces: list[surface.Surface] = []
504
- for ring in assets.fog_rings:
505
- ring_surface = pygame.Surface((width, height), pygame.SRCALPHA)
506
- pattern = _get_hatch_pattern(
507
- fog_data,
508
- ring.thickness,
509
- color=base_color,
510
- )
511
- p_w, p_h = pattern.get_size()
512
- for y in range(0, height, p_h):
513
- for x in range(0, width, p_w):
514
- ring_surface.blit(pattern, (x, y))
515
- radius = int(assets.fov_radius * ring.radius_factor * ring_scale)
516
- pygame.draw.circle(ring_surface, (0, 0, 0, 0), center, radius)
517
- ring_surfaces.append(ring_surface)
518
-
519
- combined_surface = hard_surface.copy()
520
- for ring_surface in ring_surfaces:
521
- combined_surface.blit(ring_surface, (0, 0))
522
-
523
- visible_fade_surface = _build_flashlight_fade_surface(
524
- (width, height), center, max_radius
525
- )
526
- combined_surface.blit(visible_fade_surface, (0, 0))
527
-
528
- overlay_entry = {
529
- "hard": hard_surface,
530
- "rings": ring_surfaces,
531
- "combined": combined_surface,
532
- }
533
- overlays[key] = overlay_entry
534
- return overlay_entry
535
-
536
-
537
- def _build_flashlight_fade_surface(
538
- size: tuple[int, int],
539
- center: tuple[int, int],
540
- max_radius: int,
541
- *,
542
- start_ratio: float = 0.2,
543
- max_alpha: int = 220,
544
- outer_extension: int = 30,
545
- ) -> surface.Surface:
546
- """Return a radial gradient so flashlight edges softly darken again."""
547
-
548
- width, height = size
549
- fade_surface = pygame.Surface((width, height), pygame.SRCALPHA)
550
- fade_surface.fill((0, 0, 0, 0))
551
-
552
- start_radius = max(0.0, min(max_radius, max_radius * start_ratio))
553
- end_radius = max(start_radius + 1, max_radius + outer_extension)
554
- fade_range = max(1.0, end_radius - start_radius)
555
-
556
- alpha_view = None
557
- if pg_surfarray is not None:
558
- alpha_view = pg_surfarray.pixels_alpha(fade_surface)
559
- else: # pragma: no cover - numpy-less fallback
560
- fade_surface.lock()
561
-
562
- cx, cy = center
563
- for y in range(height):
564
- dy = y - cy
565
- for x in range(width):
566
- dx = x - cx
567
- dist = math.hypot(dx, dy)
568
- if dist > end_radius:
569
- dist = end_radius
570
- if dist <= start_radius:
571
- alpha = 0
572
- else:
573
- progress = min(1.0, (dist - start_radius) / fade_range)
574
- alpha = int(max_alpha * progress)
575
- if alpha <= 0:
576
- continue
577
- if alpha_view is not None:
578
- alpha_view[x, y] = alpha
579
- else:
580
- fade_surface.set_at((x, y), (0, 0, 0, alpha))
581
-
582
- if alpha_view is not None:
583
- del alpha_view
584
- else: # pragma: no cover
585
- fade_surface.unlock()
586
-
587
- return fade_surface
588
-
589
-
590
- def _draw_fall_whirlwind(
591
- screen: surface.Surface,
592
- camera: Camera,
593
- center: tuple[int, int],
594
- progress: float,
595
- *,
596
- scale: float = 1.0,
597
- ) -> None:
598
- base_alpha = FALLING_WHIRLWIND_COLOR[3]
599
- alpha = int(max(0, min(255, base_alpha * (1.0 - progress))))
600
- if alpha <= 0:
601
- return
602
- color = (
603
- FALLING_WHIRLWIND_COLOR[0],
604
- FALLING_WHIRLWIND_COLOR[1],
605
- FALLING_WHIRLWIND_COLOR[2],
606
- alpha,
607
- )
608
- safe_scale = max(0.4, scale)
609
- swirl_radius = max(2, int(ZOMBIE_RADIUS * 1.1 * safe_scale))
610
- offset = max(1, int(ZOMBIE_RADIUS * 0.6 * safe_scale))
611
- size = swirl_radius * 4
612
- swirl = pygame.Surface((size, size), pygame.SRCALPHA)
613
- cx = cy = size // 2
614
- for idx in range(2):
615
- angle = progress * math.tau * 0.3 + idx * (math.tau / 2)
616
- ox = int(math.cos(angle) * offset)
617
- oy = int(math.sin(angle) * offset)
618
- pygame.draw.circle(swirl, color, (cx + ox, cy + oy), swirl_radius, width=2)
619
- world_rect = pygame.Rect(0, 0, 1, 1)
620
- world_rect.center = center
621
- screen_center = camera.apply_rect(world_rect).center
622
- screen.blit(swirl, swirl.get_rect(center=screen_center))
623
-
624
-
625
- def _draw_falling_fx(
626
- screen: surface.Surface,
627
- camera: Camera,
628
- falling_zombies: list[FallingZombie],
629
- flashlight_count: int,
630
- dust_rings: list[DustRing],
631
- ) -> None:
632
- if not falling_zombies and not dust_rings:
633
- return
634
- now = pygame.time.get_ticks()
635
- for fall in falling_zombies:
636
- pre_fx_ms = max(0, fall.pre_fx_ms)
637
- fall_duration_ms = max(1, fall.fall_duration_ms)
638
- fall_start = fall.started_at_ms + pre_fx_ms
639
- impact_at = fall_start + fall_duration_ms
640
- if now < fall_start:
641
- if flashlight_count > 0 and pre_fx_ms > 0:
642
- fx_progress = max(0.0, min(1.0, (now - fall.started_at_ms) / pre_fx_ms))
643
- # Make the premonition grow with the impending drop scale.
644
- pre_scale = 1.0 + (0.9 * fx_progress)
645
- _draw_fall_whirlwind(
646
- screen,
647
- camera,
648
- fall.start_pos,
649
- fx_progress,
650
- scale=pre_scale,
651
- )
652
- continue
653
- if now >= impact_at:
654
- continue
655
- fall_progress = max(0.0, min(1.0, (now - fall_start) / fall_duration_ms))
656
-
657
- if getattr(fall, "mode", "spawn") == "pitfall":
658
- scale = 1.0 - fall_progress
659
- scale = scale * scale
660
- y_offset = 0.0
661
- else:
662
- eased = 1.0 - (1.0 - fall_progress) * (1.0 - fall_progress)
663
- scale = 2.0 - (1.0 * eased)
664
- # Add an extra vertical drop from above (1.5x wall depth)
665
- y_offset = -INTERNAL_WALL_BEVEL_DEPTH * 1.5 * (1.0 - eased)
666
-
667
- radius = ZOMBIE_RADIUS * scale
668
- cx = fall.target_pos[0]
669
- cy = fall.target_pos[1] + ZOMBIE_RADIUS - radius + y_offset
670
-
671
- world_rect = pygame.Rect(0, 0, radius * 2, radius * 2)
672
- world_rect.center = (int(cx), int(cy))
673
- screen_rect = camera.apply_rect(world_rect)
674
- pygame.draw.circle(
675
- screen,
676
- FALLING_ZOMBIE_COLOR,
677
- screen_rect.center,
678
- max(1, int(screen_rect.width / 2)),
679
- )
680
-
681
- for ring in list(dust_rings):
682
- elapsed = now - ring.started_at_ms
683
- if elapsed >= ring.duration_ms:
684
- dust_rings.remove(ring)
685
- continue
686
- progress = max(0.0, min(1.0, elapsed / ring.duration_ms))
687
- alpha = int(max(0, min(255, FALLING_DUST_COLOR[3] * (1.0 - progress))))
688
- if alpha <= 0:
689
- continue
690
- radius = int(ZOMBIE_RADIUS * (0.7 + progress * 1.9))
691
- color = (
692
- FALLING_DUST_COLOR[0],
693
- FALLING_DUST_COLOR[1],
694
- FALLING_DUST_COLOR[2],
695
- alpha,
696
- )
697
- world_rect = pygame.Rect(0, 0, 1, 1)
698
- world_rect.center = ring.pos
699
- screen_center = camera.apply_rect(world_rect).center
700
- pygame.draw.circle(screen, color, screen_center, radius, width=2)
701
-
702
-
703
- def _draw_hint_arrow(
704
- screen: surface.Surface,
705
- camera: Camera,
706
- assets: RenderAssets,
707
- player: Player,
708
- target_pos: tuple[int, int],
709
- *,
710
- color: tuple[int, int, int] | None = None,
711
- ring_radius: float | None = None,
712
- ) -> None:
713
- """Draw a soft directional hint from player to a target position."""
714
- color = color or YELLOW
715
- player_screen = camera.apply(player).center
716
- target_rect = pygame.Rect(target_pos[0], target_pos[1], 0, 0)
717
- target_screen = camera.apply_rect(target_rect).center
718
- dx = target_screen[0] - player_screen[0]
719
- dy = target_screen[1] - player_screen[1]
720
- dist = math.hypot(dx, dy)
721
- if dist < 10:
722
- return
723
- dir_x = dx / dist
724
- dir_y = dy / dist
725
- ring_radius = (
726
- ring_radius
727
- if ring_radius is not None
728
- else assets.fov_radius * 0.5 * assets.fog_radius_scale
729
- )
730
- center_x = player_screen[0] + dir_x * ring_radius
731
- center_y = player_screen[1] + dir_y * ring_radius
732
- arrow_len = 6
733
- tip = (center_x + dir_x * arrow_len, center_y + dir_y * arrow_len)
734
- base = (center_x - dir_x * 6, center_y - dir_y * 6)
735
- left = (
736
- base[0] - dir_y * 5,
737
- base[1] + dir_x * 5,
738
- )
739
- right = (
740
- base[0] + dir_y * 5,
741
- base[1] - dir_x * 5,
742
- )
743
- pygame.draw.polygon(screen, color, [tip, left, right])
744
-
745
-
746
- def _draw_status_bar(
747
- screen: surface.Surface,
748
- assets: RenderAssets,
749
- config: dict[str, Any],
750
- *,
751
- stage: Stage | None = None,
752
- seed: int | None = None,
753
- debug_mode: bool = False,
754
- zombie_group: sprite.Group | None = None,
755
- falling_spawn_carry: int | None = None,
756
- show_fps: bool = False,
757
- fps: float | None = None,
758
- ) -> None:
759
- """Render a compact status bar with current config flags and stage info."""
760
- bar_rect = pygame.Rect(
761
- 0,
762
- assets.screen_height - assets.status_bar_height,
763
- assets.screen_width,
764
- assets.status_bar_height,
765
- )
766
- overlay = pygame.Surface((bar_rect.width, bar_rect.height), pygame.SRCALPHA)
767
- overlay.fill((0, 0, 0, 140))
768
- screen.blit(overlay, bar_rect.topleft)
769
-
770
- footprints_on = config.get("footprints", {}).get("enabled", True)
771
- fast_on = config.get("fast_zombies", {}).get("enabled", True)
772
- hint_on = config.get("car_hint", {}).get("enabled", True)
773
- steel_on = config.get("steel_beams", {}).get("enabled", False)
774
- if stage:
775
- # Keep the label compact for the status bar
776
- if stage.id.startswith("stage"):
777
- stage_suffix = stage.id.removeprefix("stage")
778
- stage_label = f"#{stage_suffix}" if stage_suffix else stage.id
779
- else:
780
- stage_label = stage.id
781
- else:
782
- stage_label = "#1"
783
-
784
- parts = [tr("status.stage", label=stage_label)]
785
- if footprints_on:
786
- parts.append(tr("status.footprints"))
787
- if hint_on:
788
- parts.append(tr("status.car_hint"))
789
- if fast_on:
790
- parts.append(tr("status.fast"))
791
- if steel_on:
792
- parts.append(tr("status.steel"))
793
- if debug_mode:
794
- if zombie_group is not None:
795
- zombies = [z for z in zombie_group if z.alive()]
796
- total = len(zombies)
797
- tracker = sum(1 for z in zombies if z.tracker)
798
- wall = sum(1 for z in zombies if z.wall_hugging)
799
- normal = max(0, total - tracker - wall)
800
- debug_counts = f"Z:{total} N:{normal} T:{tracker} W:{wall}"
801
- if falling_spawn_carry is not None:
802
- debug_counts = f"{debug_counts} C:{max(0, falling_spawn_carry)}"
803
- parts.append(debug_counts)
804
- status_text = " | ".join(parts)
805
- color = LIGHT_GRAY
806
-
807
- try:
808
- font_settings = get_font_settings()
809
- font = load_font(font_settings.resource, font_settings.scaled_size(11))
810
- text_surface = font.render(status_text, False, color)
811
- text_rect = text_surface.get_rect(left=12, centery=bar_rect.centery)
812
- screen.blit(text_surface, text_rect)
813
- if seed is not None:
814
- seed_text = tr("status.seed", value=str(seed))
815
- seed_surface = font.render(seed_text, False, LIGHT_GRAY)
816
- seed_rect = seed_surface.get_rect(
817
- right=bar_rect.right - 12, centery=bar_rect.centery
818
- )
819
- screen.blit(seed_surface, seed_rect)
820
- if show_fps and fps is not None:
821
- fps_text = f"FPS:{fps:.1f}"
822
- fps_surface = font.render(fps_text, False, LIGHT_GRAY)
823
- fps_rect = fps_surface.get_rect(
824
- left=12,
825
- bottom=max(2, bar_rect.top - 4),
826
- )
827
- screen.blit(fps_surface, fps_rect)
828
- except pygame.error as e:
829
- print(f"Error rendering status bar: {e}")
830
-
831
-
832
- def _draw_play_area(
833
- screen: surface.Surface,
834
- camera: Camera,
835
- assets: RenderAssets,
836
- palette: Any,
837
- field_rect: pygame.Rect,
838
- outside_cells: set[tuple[int, int]],
839
- fall_spawn_cells: set[tuple[int, int]],
840
- pitfall_cells: set[tuple[int, int]],
841
- ) -> tuple[int, int, int, int, set[tuple[int, int]]]:
842
- xs, ys, xe, ye = (
843
- field_rect.left,
844
- field_rect.top,
845
- field_rect.right,
846
- field_rect.bottom,
847
- )
848
- xs //= assets.internal_wall_grid_snap
849
- ys //= assets.internal_wall_grid_snap
850
- xe //= assets.internal_wall_grid_snap
851
- ye //= assets.internal_wall_grid_snap
852
-
853
- play_area_rect = pygame.Rect(
854
- xs * assets.internal_wall_grid_snap,
855
- ys * assets.internal_wall_grid_snap,
856
- (xe - xs) * assets.internal_wall_grid_snap,
857
- (ye - ys) * assets.internal_wall_grid_snap,
858
- )
859
- play_area_screen_rect = camera.apply_rect(play_area_rect)
860
- pygame.draw.rect(screen, palette.floor_primary, play_area_screen_rect)
861
-
862
- view_world = pygame.Rect(
863
- -camera.camera.x,
864
- -camera.camera.y,
865
- assets.screen_width,
866
- assets.screen_height,
867
- )
868
- margin = assets.internal_wall_grid_snap * 2
869
- view_world.inflate_ip(margin * 2, margin * 2)
870
- min_world_x = max(xs * assets.internal_wall_grid_snap, view_world.left)
871
- max_world_x = min(xe * assets.internal_wall_grid_snap, view_world.right)
872
- min_world_y = max(ys * assets.internal_wall_grid_snap, view_world.top)
873
- max_world_y = min(ye * assets.internal_wall_grid_snap, view_world.bottom)
874
- start_x = max(xs, int(min_world_x // assets.internal_wall_grid_snap))
875
- end_x = min(xe, int(math.ceil(max_world_x / assets.internal_wall_grid_snap)))
876
- start_y = max(ys, int(min_world_y // assets.internal_wall_grid_snap))
877
- end_y = min(ye, int(math.ceil(max_world_y / assets.internal_wall_grid_snap)))
878
-
879
- for y in range(start_y, end_y):
880
- for x in range(start_x, end_x):
881
- if (x, y) in outside_cells:
882
- lx, ly = (
883
- x * assets.internal_wall_grid_snap,
884
- y * assets.internal_wall_grid_snap,
885
- )
886
- r = pygame.Rect(
887
- lx,
888
- ly,
889
- assets.internal_wall_grid_snap,
890
- assets.internal_wall_grid_snap,
891
- )
892
- sr = camera.apply_rect(r)
893
- if sr.colliderect(screen.get_rect()):
894
- pygame.draw.rect(screen, palette.outside, sr)
895
- continue
896
-
897
- if (x, y) in pitfall_cells:
898
- lx, ly = (
899
- x * assets.internal_wall_grid_snap,
900
- y * assets.internal_wall_grid_snap,
901
- )
902
- r = pygame.Rect(
903
- lx,
904
- ly,
905
- assets.internal_wall_grid_snap,
906
- assets.internal_wall_grid_snap,
907
- )
908
- sr = camera.apply_rect(r)
909
- if not sr.colliderect(screen.get_rect()):
910
- continue
911
- pygame.draw.rect(screen, PITFALL_ABYSS_COLOR, sr)
912
-
913
- if (x, y - 1) not in pitfall_cells:
914
- edge_h = max(
915
- 1, INTERNAL_WALL_BEVEL_DEPTH - PITFALL_EDGE_DEPTH_OFFSET
916
- )
917
- pygame.draw.rect(
918
- screen, PITFALL_EDGE_METAL_COLOR, (sr.x, sr.y, sr.w, edge_h)
919
- )
920
- for sx in range(
921
- sr.x - edge_h, sr.right, PITFALL_EDGE_STRIPE_SPACING
922
- ):
923
- pygame.draw.line(
924
- screen,
925
- PITFALL_EDGE_STRIPE_COLOR,
926
- (max(sr.x, sx), sr.y),
927
- (min(sr.right - 1, sx + edge_h), sr.y + edge_h - 1),
928
- width=2,
929
- )
930
-
931
- continue
932
-
933
- use_secondary = ((x // 2) + (y // 2)) % 2 == 0
934
- if (x, y) in fall_spawn_cells:
935
- color = (
936
- palette.fall_zone_secondary
937
- if use_secondary
938
- else palette.fall_zone_primary
939
- )
940
- elif not use_secondary:
941
- continue
942
- else:
943
- color = palette.floor_secondary
944
- lx, ly = (
945
- x * assets.internal_wall_grid_snap,
946
- y * assets.internal_wall_grid_snap,
947
- )
948
- r = pygame.Rect(
949
- lx,
950
- ly,
951
- assets.internal_wall_grid_snap,
952
- assets.internal_wall_grid_snap,
953
- )
954
- sr = camera.apply_rect(r)
955
- if sr.colliderect(screen.get_rect()):
956
- pygame.draw.rect(screen, color, sr)
957
-
958
- return xs, ys, xe, ye, outside_cells
959
-
960
-
961
- def abs_clip(value: float, min_v: float, max_v: float) -> float:
962
- value_sign = 1.0 if value >= 0.0 else -1.0
963
- value = abs(value)
964
- if value < min_v:
965
- value = min_v
966
- elif value > max_v:
967
- value = max_v
968
- return value_sign * value
969
-
970
-
971
- def _draw_wall_shadows(
972
- shadow_layer: surface.Surface,
973
- camera: Camera,
974
- *,
975
- wall_cells: set[tuple[int, int]],
976
- wall_group: sprite.Group | None,
977
- outer_wall_cells: set[tuple[int, int]] | None,
978
- cell_size: int,
979
- light_source_pos: tuple[int, int] | None,
980
- alpha: int = 68,
981
- ) -> bool:
982
- if not wall_cells or cell_size <= 0 or light_source_pos is None:
983
- return False
984
- inner_wall_cells = set(wall_cells)
985
- if outer_wall_cells:
986
- inner_wall_cells.difference_update(outer_wall_cells)
987
- if wall_group and cell_size > 0:
988
- for wall in wall_group:
989
- if isinstance(wall, SteelBeam):
990
- cell_x = int(wall.rect.centerx // cell_size)
991
- cell_y = int(wall.rect.centery // cell_size)
992
- inner_wall_cells.add((cell_x, cell_y))
993
- if not inner_wall_cells:
994
- return False
995
- base_shadow_size = max(cell_size + 2, int(cell_size * 1.35))
996
- shadow_size = max(1, int(base_shadow_size * 1.5))
997
- shadow_surface = _get_shadow_tile_surface(
998
- shadow_size,
999
- alpha,
1000
- edge_softness=0.12,
1001
- )
1002
- screen_rect = shadow_layer.get_rect()
1003
- px, py = light_source_pos
1004
- drew = False
1005
- clip_max = shadow_size * 0.25
1006
- for cell_x, cell_y in inner_wall_cells:
1007
- world_x = cell_x * cell_size
1008
- world_y = cell_y * cell_size
1009
- wall_rect = pygame.Rect(world_x, world_y, cell_size, cell_size)
1010
- wall_screen_rect = camera.apply_rect(wall_rect)
1011
- if not wall_screen_rect.colliderect(screen_rect):
1012
- continue
1013
- center_x = world_x + cell_size / 2
1014
- center_y = world_y + cell_size / 2
1015
- dx = (center_x - px) * 0.5
1016
- dy = (center_y - py) * 0.5
1017
- dx = int(abs_clip(dx, 0, clip_max))
1018
- dy = int(abs_clip(dy, 0, clip_max))
1019
- shadow_rect = pygame.Rect(0, 0, shadow_size, shadow_size)
1020
- shadow_rect.center = (
1021
- int(center_x + dx),
1022
- int(center_y + dy),
1023
- )
1024
- shadow_screen_rect = camera.apply_rect(shadow_rect)
1025
- if not shadow_screen_rect.colliderect(screen_rect):
1026
- continue
1027
- shadow_layer.blit(
1028
- shadow_surface,
1029
- shadow_screen_rect.topleft,
1030
- special_flags=pygame.BLEND_RGBA_MAX,
1031
- )
1032
- drew = True
1033
- return drew
1034
-
1035
-
1036
- def _draw_entity_shadows(
1037
- shadow_layer: surface.Surface,
1038
- camera: Camera,
1039
- all_sprites: sprite.LayeredUpdates,
1040
- *,
1041
- light_source_pos: tuple[int, int] | None,
1042
- exclude_car: Car | None,
1043
- outside_cells: set[tuple[int, int]] | None,
1044
- cell_size: int,
1045
- shadow_radius: int = int(ZOMBIE_RADIUS * ENTITY_SHADOW_RADIUS_MULT),
1046
- alpha: int = ENTITY_SHADOW_ALPHA,
1047
- ) -> bool:
1048
- if light_source_pos is None or shadow_radius <= 0:
1049
- return False
1050
- if cell_size <= 0:
1051
- outside_cells = None
1052
- shadow_surface = _get_shadow_circle_surface(
1053
- shadow_radius,
1054
- alpha,
1055
- edge_softness=ENTITY_SHADOW_EDGE_SOFTNESS,
1056
- )
1057
- screen_rect = shadow_layer.get_rect()
1058
- px, py = light_source_pos
1059
- offset_dist = max(1.0, shadow_radius * 0.6)
1060
- drew = False
1061
- for entity in all_sprites:
1062
- if not entity.alive():
1063
- continue
1064
- if isinstance(entity, Player):
1065
- continue
1066
- if isinstance(entity, Car):
1067
- if exclude_car is not None and entity is exclude_car:
1068
- continue
1069
- if not isinstance(entity, (Zombie, Survivor, Car)):
1070
- continue
1071
- if outside_cells:
1072
- cell = (
1073
- int(entity.rect.centerx // cell_size),
1074
- int(entity.rect.centery // cell_size),
1075
- )
1076
- if cell in outside_cells:
1077
- continue
1078
- cx, cy = entity.rect.center
1079
- dx = cx - px
1080
- dy = cy - py
1081
- dist = math.hypot(dx, dy)
1082
- if dist > 0.001:
1083
- scale = offset_dist / dist
1084
- offset_x = dx * scale
1085
- offset_y = dy * scale
1086
- else:
1087
- offset_x = 0.0
1088
- offset_y = 0.0
1089
-
1090
- jump_dy = 0.0
1091
- if getattr(entity, "is_jumping", False):
1092
- jump_dy = JUMP_SHADOW_OFFSET
1093
-
1094
- shadow_rect = shadow_surface.get_rect(
1095
- center=(int(cx + offset_x), int(cy + offset_y + jump_dy))
1096
- )
1097
- shadow_screen_rect = camera.apply_rect(shadow_rect)
1098
- if not shadow_screen_rect.colliderect(screen_rect):
1099
- continue
1100
- shadow_layer.blit(
1101
- shadow_surface,
1102
- shadow_screen_rect.topleft,
1103
- special_flags=pygame.BLEND_RGBA_MAX,
1104
- )
1105
- drew = True
1106
- return drew
1107
-
1108
-
1109
- def _draw_single_entity_shadow(
1110
- shadow_layer: surface.Surface,
1111
- camera: Camera,
1112
- *,
1113
- entity: pygame.sprite.Sprite | None,
1114
- light_source_pos: tuple[int, int] | None,
1115
- outside_cells: set[tuple[int, int]] | None,
1116
- cell_size: int,
1117
- shadow_radius: int,
1118
- alpha: int,
1119
- edge_softness: float = ENTITY_SHADOW_EDGE_SOFTNESS,
1120
- ) -> bool:
1121
- if (
1122
- entity is None
1123
- or not entity.alive()
1124
- or light_source_pos is None
1125
- or shadow_radius <= 0
1126
- ):
1127
- return False
1128
- if outside_cells and cell_size > 0:
1129
- cell = (
1130
- int(entity.rect.centerx // cell_size),
1131
- int(entity.rect.centery // cell_size),
1132
- )
1133
- if cell in outside_cells:
1134
- return False
1135
- shadow_surface = _get_shadow_circle_surface(
1136
- shadow_radius,
1137
- alpha,
1138
- edge_softness=edge_softness,
1139
- )
1140
- screen_rect = shadow_layer.get_rect()
1141
- px, py = light_source_pos
1142
- cx, cy = entity.rect.center
1143
- dx = cx - px
1144
- dy = cy - py
1145
- dist = math.hypot(dx, dy)
1146
- offset_dist = max(1.0, shadow_radius * 0.6)
1147
- if dist > 0.001:
1148
- scale = offset_dist / dist
1149
- offset_x = dx * scale
1150
- offset_y = dy * scale
1151
- else:
1152
- offset_x = 0.0
1153
- offset_y = 0.0
1154
-
1155
- jump_dy = 0.0
1156
- if getattr(entity, "is_jumping", False):
1157
- jump_dy = JUMP_SHADOW_OFFSET
1158
-
1159
- shadow_rect = shadow_surface.get_rect(
1160
- center=(int(cx + offset_x), int(cy + offset_y + jump_dy))
1161
- )
1162
- shadow_screen_rect = camera.apply_rect(shadow_rect)
1163
- if not shadow_screen_rect.colliderect(screen_rect):
1164
- return False
1165
- shadow_layer.blit(
1166
- shadow_surface,
1167
- shadow_screen_rect.topleft,
1168
- special_flags=pygame.BLEND_RGBA_MAX,
1169
- )
1170
- return True
1171
-
1172
-
1173
- def _draw_footprints(
1174
- screen: surface.Surface,
1175
- camera: Camera,
1176
- assets: RenderAssets,
1177
- footprints: list[Footprint],
1178
- *,
1179
- config: dict[str, Any],
1180
- ) -> None:
1181
- if not config.get("footprints", {}).get("enabled", True):
1182
- return
1183
- now = pygame.time.get_ticks()
1184
- for fp in footprints:
1185
- age = now - fp.time
1186
- fade = 1 - (age / assets.footprint_lifetime_ms)
1187
- fade = max(assets.footprint_min_fade, fade)
1188
- color = tuple(int(c * fade) for c in FOOTPRINT_COLOR)
1189
- fp_rect = pygame.Rect(
1190
- fp.pos[0] - assets.footprint_radius,
1191
- fp.pos[1] - assets.footprint_radius,
1192
- assets.footprint_radius * 2,
1193
- assets.footprint_radius * 2,
1194
- )
1195
- sr = camera.apply_rect(fp_rect)
1196
- if sr.colliderect(screen.get_rect().inflate(30, 30)):
1197
- pygame.draw.circle(screen, color, sr.center, assets.footprint_radius)
1198
-
1199
-
1200
- def _draw_entities(
1201
- screen: surface.Surface,
1202
- camera: Camera,
1203
- all_sprites: sprite.LayeredUpdates,
1204
- player: Player,
1205
- *,
1206
- has_fuel: bool,
1207
- ) -> pygame.Rect:
1208
- screen_rect_inflated = screen.get_rect().inflate(100, 100)
1209
- player_screen_rect: pygame.Rect | None = None
1210
- for entity in all_sprites:
1211
- sprite_screen_rect = camera.apply_rect(entity.rect)
1212
- if sprite_screen_rect.colliderect(screen_rect_inflated):
1213
- screen.blit(entity.image, sprite_screen_rect)
1214
- if entity is player:
1215
- player_screen_rect = sprite_screen_rect
1216
- _draw_fuel_indicator(
1217
- screen,
1218
- player_screen_rect,
1219
- has_fuel=has_fuel,
1220
- in_car=player.in_car,
1221
- )
1222
- return player_screen_rect or camera.apply_rect(player.rect)
1223
-
1224
-
1225
- def _draw_fuel_indicator(
1226
- screen: surface.Surface,
1227
- player_screen_rect: pygame.Rect,
1228
- *,
1229
- has_fuel: bool,
1230
- in_car: bool,
1231
- ) -> None:
1232
- if not has_fuel or in_car:
1233
- return
1234
- indicator_size = 4
1235
- padding = 1
1236
- indicator_rect = pygame.Rect(
1237
- player_screen_rect.right - indicator_size - padding,
1238
- player_screen_rect.bottom - indicator_size - padding,
1239
- indicator_size,
1240
- indicator_size,
1241
- )
1242
- pygame.draw.rect(screen, YELLOW, indicator_rect)
1243
- pygame.draw.rect(screen, (180, 160, 40), indicator_rect, width=1)
1244
-
1245
-
1246
- def _draw_hint_indicator(
1247
- screen: surface.Surface,
1248
- camera: Camera,
1249
- assets: RenderAssets,
1250
- player: Player,
1251
- hint_target: tuple[int, int] | None,
1252
- *,
1253
- hint_color: tuple[int, int, int],
1254
- stage: Stage | None,
1255
- flashlight_count: int,
1256
- ) -> None:
1257
- if not hint_target:
1258
- return
1259
- current_fov_scale = _get_fog_scale(assets, flashlight_count)
1260
- hint_ring_radius = assets.fov_radius * 0.5 * current_fov_scale
1261
- _draw_hint_arrow(
1262
- screen,
1263
- camera,
1264
- assets,
1265
- player,
1266
- hint_target,
1267
- color=hint_color,
1268
- ring_radius=hint_ring_radius,
1269
- )
1270
-
1271
-
1272
- def _draw_fog_of_war(
1273
- screen: surface.Surface,
1274
- camera: Camera,
1275
- assets: RenderAssets,
1276
- fog_surfaces: dict[str, Any],
1277
- fov_target: pygame.sprite.Sprite | None,
1278
- *,
1279
- stage: Stage | None,
1280
- flashlight_count: int,
1281
- dawn_ready: bool,
1282
- ) -> None:
1283
- if fov_target is None:
1284
- return
1285
- fov_center_on_screen = list(camera.apply(fov_target).center)
1286
- cam_rect = camera.camera
1287
- horizontal_span = camera.width - assets.screen_width
1288
- vertical_span = camera.height - assets.screen_height
1289
- if horizontal_span <= 0 or (cam_rect.x != 0 and cam_rect.x != -horizontal_span):
1290
- fov_center_on_screen[0] = assets.screen_width // 2
1291
- if vertical_span <= 0 or (cam_rect.y != 0 and cam_rect.y != -vertical_span):
1292
- fov_center_on_screen[1] = assets.screen_height // 2
1293
- fov_center_tuple = (int(fov_center_on_screen[0]), int(fov_center_on_screen[1]))
1294
- if dawn_ready:
1295
- profile = FogProfile.DAWN
1296
- else:
1297
- profile = FogProfile._from_flashlight_count(flashlight_count)
1298
- overlay = _get_fog_overlay_surfaces(
1299
- fog_surfaces,
1300
- assets,
1301
- profile,
1302
- stage=stage,
1303
- )
1304
- combined_surface: surface.Surface = overlay["combined"]
1305
- screen.blit(
1306
- combined_surface,
1307
- combined_surface.get_rect(center=fov_center_tuple),
1308
- )
1309
-
1310
-
1311
- def _draw_need_fuel_message(
1312
- screen: surface.Surface,
1313
- assets: RenderAssets,
1314
- *,
1315
- has_fuel: bool,
1316
- fuel_message_until: int,
1317
- elapsed_play_ms: int,
1318
- ) -> None:
1319
- if has_fuel or fuel_message_until <= elapsed_play_ms:
1320
- return
1321
- show_message(
1322
- screen,
1323
- tr("hud.need_fuel"),
1324
- 18,
1325
- ORANGE,
1326
- (assets.screen_width // 2, assets.screen_height // 2),
1327
- )
1328
-
1329
-
1330
- def _draw_objective(lines: list[str], *, screen: surface.Surface) -> None:
1331
- try:
1332
- font_settings = get_font_settings()
1333
- font = load_font(font_settings.resource, font_settings.scaled_size(11))
1334
- y = 8
1335
- for line in lines:
1336
- text_surface = font.render(line, False, YELLOW)
1337
- text_rect = text_surface.get_rect(topleft=(12, y))
1338
- screen.blit(text_surface, text_rect)
1339
- y += text_rect.height + 4
1340
- except pygame.error as e:
1341
- print(f"Error rendering objective: {e}")
1342
-
1343
-
1344
- def _draw_inventory_icons(
1345
- screen: surface.Surface,
1346
- assets: RenderAssets,
1347
- *,
1348
- has_fuel: bool,
1349
- flashlight_count: int,
1350
- shoes_count: int,
1351
- ) -> None:
1352
- icons: list[surface.Surface] = []
1353
- if has_fuel:
1354
- icons.append(_get_hud_icon("fuel"))
1355
- for _ in range(max(0, int(flashlight_count))):
1356
- icons.append(_get_hud_icon("flashlight"))
1357
- for _ in range(max(0, int(shoes_count))):
1358
- icons.append(_get_hud_icon("shoes"))
1359
- if not icons:
1360
- return
1361
- spacing = 3
1362
- padding = 8
1363
- total_width = sum(icon.get_width() for icon in icons)
1364
- total_width += spacing * max(0, len(icons) - 1)
1365
- start_x = assets.screen_width - padding - total_width
1366
- y = 8
1367
- x = max(padding, start_x)
1368
- for icon in icons:
1369
- screen.blit(icon, (x, y))
1370
- x += icon.get_width() + spacing
1371
-
1372
-
1373
- def _draw_endurance_timer(
1374
- screen: surface.Surface,
1375
- assets: RenderAssets,
1376
- *,
1377
- stage: Stage | None,
1378
- state: Any,
1379
- ) -> None:
1380
- if not (stage and stage.endurance_stage):
1381
- return
1382
- goal_ms = state.endurance_goal_ms
1383
- if goal_ms <= 0:
1384
- return
1385
- elapsed_ms = max(0, min(goal_ms, state.endurance_elapsed_ms))
1386
- remaining_ms = max(0, goal_ms - elapsed_ms)
1387
- padding = 12
1388
- bar_height = 8
1389
- y_pos = assets.screen_height - assets.status_bar_height - bar_height - 10
1390
- bar_rect = pygame.Rect(
1391
- padding,
1392
- y_pos,
1393
- assets.screen_width - padding * 2,
1394
- bar_height,
1395
- )
1396
- track_surface = pygame.Surface((bar_rect.width, bar_rect.height), pygame.SRCALPHA)
1397
- track_surface.fill((0, 0, 0, 140))
1398
- screen.blit(track_surface, bar_rect.topleft)
1399
- progress_ratio = elapsed_ms / goal_ms if goal_ms else 0.0
1400
- progress_width = int(bar_rect.width * max(0.0, min(1.0, progress_ratio)))
1401
- if progress_width > 0:
1402
- fill_color = (120, 20, 20)
1403
- if state.dawn_ready:
1404
- fill_color = (25, 40, 120)
1405
- fill_rect = pygame.Rect(
1406
- bar_rect.left,
1407
- bar_rect.top,
1408
- progress_width,
1409
- bar_rect.height,
1410
- )
1411
- pygame.draw.rect(screen, fill_color, fill_rect)
1412
- display_ms = int(remaining_ms * SURVIVAL_FAKE_CLOCK_RATIO)
1413
- display_ms = max(0, display_ms)
1414
- display_hours = display_ms // 3_600_000
1415
- display_minutes = (display_ms % 3_600_000) // 60_000
1416
- display_label = f"{int(display_hours):02d}:{int(display_minutes):02d}"
1417
- timer_text = tr("hud.endurance_timer_label", time=display_label)
1418
- try:
1419
- font_settings = get_font_settings()
1420
- font = load_font(font_settings.resource, font_settings.scaled_size(12))
1421
- text_surface = font.render(timer_text, False, LIGHT_GRAY)
1422
- text_rect = text_surface.get_rect(left=bar_rect.left, bottom=bar_rect.top - 2)
1423
- screen.blit(text_surface, text_rect)
1424
- if state.time_accel_active:
1425
- accel_text = tr("hud.time_accel")
1426
- accel_surface = font.render(accel_text, False, YELLOW)
1427
- accel_rect = accel_surface.get_rect(
1428
- right=bar_rect.right, bottom=bar_rect.top - 2
1429
- )
1430
- screen.blit(accel_surface, accel_rect)
1431
- else:
1432
- hint_text = tr("hud.time_accel_hint")
1433
- hint_surface = font.render(hint_text, False, LIGHT_GRAY)
1434
- hint_rect = hint_surface.get_rect(
1435
- right=bar_rect.right, bottom=bar_rect.top - 2
1436
- )
1437
- screen.blit(hint_surface, hint_rect)
1438
- except pygame.error as e:
1439
- print(f"Error rendering endurance timer: {e}")
1440
-
1441
-
1442
- def _draw_time_accel_indicator(
1443
- screen: surface.Surface,
1444
- assets: RenderAssets,
1445
- *,
1446
- stage: Stage | None,
1447
- state: Any,
1448
- ) -> None:
1449
- if stage and stage.endurance_stage:
1450
- return
1451
- try:
1452
- font_settings = get_font_settings()
1453
- font = load_font(font_settings.resource, font_settings.scaled_size(12))
1454
- if state.time_accel_active:
1455
- text = tr("hud.time_accel")
1456
- color = YELLOW
1457
- else:
1458
- text = tr("hud.time_accel_hint")
1459
- color = LIGHT_GRAY
1460
- text_surface = font.render(text, False, color)
1461
- bottom_margin = assets.status_bar_height + 6
1462
- text_rect = text_surface.get_rect(
1463
- right=assets.screen_width - 12,
1464
- bottom=assets.screen_height - bottom_margin,
1465
- )
1466
- screen.blit(text_surface, text_rect)
1467
- except pygame.error as e:
1468
- print(f"Error rendering acceleration indicator: {e}")
1469
-
1470
-
1471
- def _draw_survivor_messages(
1472
- screen: surface.Surface,
1473
- assets: RenderAssets,
1474
- survivor_messages: list[dict[str, Any]],
1475
- ) -> None:
1476
- if not survivor_messages:
1477
- return
1478
- try:
1479
- font_settings = get_font_settings()
1480
- font = load_font(font_settings.resource, font_settings.scaled_size(14))
1481
- base_y = assets.screen_height // 2 - 70
1482
- for idx, message in enumerate(survivor_messages[:3]):
1483
- text = message.get("text", "")
1484
- if not text:
1485
- continue
1486
- msg_surface = font.render(text, False, ORANGE)
1487
- msg_rect = msg_surface.get_rect(
1488
- center=(assets.screen_width // 2, base_y + idx * 18)
1489
- )
1490
- screen.blit(msg_surface, msg_rect)
1491
- except pygame.error as e:
1492
- print(f"Error rendering survivor message: {e}")
1493
-
1494
-
1495
- def _build_objective_lines(
1496
- *,
1497
- stage: Stage | None,
1498
- state: Any,
1499
- player: Player,
1500
- active_car: Car | None,
1501
- has_fuel: bool,
1502
- buddy_onboard: int,
1503
- buddy_required: int,
1504
- survivors_onboard: int,
1505
- ) -> list[str]:
1506
- objective_lines: list[str] = []
1507
- if stage and stage.endurance_stage:
1508
- if state.dawn_ready:
1509
- objective_lines.append(tr("objectives.get_outside"))
1510
- else:
1511
- objective_lines.append(tr("objectives.survive_until_dawn"))
1512
- elif stage and stage.buddy_required_count > 0:
1513
- buddy_ready = buddy_onboard >= buddy_required
1514
- if not active_car:
1515
- objective_lines.append(tr("objectives.pickup_buddy"))
1516
- if stage.requires_fuel and not has_fuel:
1517
- objective_lines.append(tr("objectives.find_fuel"))
1518
- else:
1519
- objective_lines.append(tr("objectives.find_car"))
1520
- else:
1521
- if stage.requires_fuel and not has_fuel:
1522
- objective_lines.append(tr("objectives.find_fuel"))
1523
- elif not buddy_ready:
1524
- objective_lines.append(tr("objectives.board_buddy"))
1525
- objective_lines.append(
1526
- tr("objectives.buddy_onboard", count=buddy_onboard)
1527
- )
1528
- objective_lines.append(tr("objectives.escape"))
1529
- else:
1530
- objective_lines.append(tr("objectives.escape"))
1531
- elif stage and stage.requires_fuel and not has_fuel:
1532
- objective_lines.append(tr("objectives.find_fuel"))
1533
- elif stage and stage.rescue_stage:
1534
- if not player.in_car:
1535
- objective_lines.append(tr("objectives.find_car"))
1536
- else:
1537
- objective_lines.append(tr("objectives.escape_with_survivors"))
1538
- elif not player.in_car:
1539
- objective_lines.append(tr("objectives.find_car"))
1540
- else:
1541
- objective_lines.append(tr("objectives.escape"))
1542
-
1543
- if stage and stage.rescue_stage and (survivors_onboard is not None):
1544
- limit = state.survivor_capacity
1545
- objective_lines.append(
1546
- tr("objectives.survivors_onboard", count=survivors_onboard, limit=limit)
1547
- )
1548
- return objective_lines
1549
-
1550
-
1551
- def draw(
1552
- assets: RenderAssets,
1553
- screen: surface.Surface,
1554
- game_data: GameData,
1555
- *,
1556
- config: dict[str, Any],
1557
- hint_target: tuple[int, int] | None = None,
1558
- hint_color: tuple[int, int, int] | None = None,
1559
- do_flip: bool = True,
1560
- present_fn: Callable[[surface.Surface], None] | None = None,
1561
- fps: float | None = None,
1562
- ) -> None:
1563
- hint_color = hint_color or YELLOW
1564
- state = game_data.state
1565
- player = game_data.player
1566
- if player is None:
1567
- raise ValueError("draw requires an active player on game_data")
1568
-
1569
- camera = game_data.camera
1570
- stage = game_data.stage
1571
- field_rect = game_data.layout.field_rect
1572
- outside_cells = game_data.layout.outside_cells
1573
- all_sprites = game_data.groups.all_sprites
1574
- fog_surfaces = game_data.fog
1575
- footprints = state.footprints
1576
- has_fuel = state.has_fuel
1577
- flashlight_count = state.flashlight_count
1578
- shoes_count = state.shoes_count
1579
- elapsed_play_ms = state.elapsed_play_ms
1580
- fuel_message_until = state.fuel_message_until
1581
- buddy_onboard = state.buddy_onboard
1582
- buddy_required = stage.buddy_required_count if stage else 0
1583
- survivors_onboard = state.survivors_onboard
1584
- survivor_messages = list(state.survivor_messages)
1585
- zombie_group = game_data.groups.zombie_group
1586
- active_car = game_data.car if game_data.car and game_data.car.alive() else None
1587
- if player.in_car and game_data.car and game_data.car.alive():
1588
- fov_target = game_data.car
1589
- else:
1590
- fov_target = player
1591
-
1592
- palette = get_environment_palette(state.ambient_palette_key)
1593
- screen.fill(palette.outside)
1594
-
1595
- _draw_play_area(
1596
- screen,
1597
- camera,
1598
- assets,
1599
- palette,
1600
- field_rect,
1601
- outside_cells,
1602
- game_data.layout.fall_spawn_cells,
1603
- game_data.layout.pitfall_cells,
1604
- )
1605
- shadow_layer = _get_shadow_layer(screen.get_size())
1606
- shadow_layer.fill((0, 0, 0, 0))
1607
- drew_shadow = _draw_wall_shadows(
1608
- shadow_layer,
1609
- camera,
1610
- wall_cells=game_data.layout.wall_cells,
1611
- wall_group=game_data.groups.wall_group,
1612
- outer_wall_cells=game_data.layout.outer_wall_cells,
1613
- cell_size=game_data.cell_size,
1614
- light_source_pos=(
1615
- None
1616
- if (stage and stage.endurance_stage and state.dawn_ready)
1617
- else fov_target.rect.center
1618
- )
1619
- if fov_target
1620
- else None,
1621
- )
1622
- drew_shadow |= _draw_entity_shadows(
1623
- shadow_layer,
1624
- camera,
1625
- all_sprites,
1626
- light_source_pos=fov_target.rect.center if fov_target else None,
1627
- exclude_car=active_car if player.in_car else None,
1628
- outside_cells=outside_cells,
1629
- cell_size=game_data.cell_size,
1630
- )
1631
- player_shadow_alpha = max(1, int(ENTITY_SHADOW_ALPHA * PLAYER_SHADOW_ALPHA_MULT))
1632
- player_shadow_radius = int(ZOMBIE_RADIUS * PLAYER_SHADOW_RADIUS_MULT)
1633
- if player.in_car:
1634
- drew_shadow |= _draw_single_entity_shadow(
1635
- shadow_layer,
1636
- camera,
1637
- entity=active_car,
1638
- light_source_pos=fov_target.rect.center if fov_target else None,
1639
- outside_cells=outside_cells,
1640
- cell_size=game_data.cell_size,
1641
- shadow_radius=player_shadow_radius,
1642
- alpha=player_shadow_alpha,
1643
- )
1644
- else:
1645
- drew_shadow |= _draw_single_entity_shadow(
1646
- shadow_layer,
1647
- camera,
1648
- entity=player,
1649
- light_source_pos=fov_target.rect.center if fov_target else None,
1650
- outside_cells=outside_cells,
1651
- cell_size=game_data.cell_size,
1652
- shadow_radius=player_shadow_radius,
1653
- alpha=player_shadow_alpha,
1654
- )
1655
- if drew_shadow:
1656
- screen.blit(shadow_layer, (0, 0))
1657
- _draw_footprints(
1658
- screen,
1659
- camera,
1660
- assets,
1661
- footprints,
1662
- config=config,
1663
- )
1664
- _draw_entities(
1665
- screen,
1666
- camera,
1667
- all_sprites,
1668
- player,
1669
- has_fuel=has_fuel,
1670
- )
1671
-
1672
- _draw_falling_fx(
1673
- screen,
1674
- camera,
1675
- state.falling_zombies,
1676
- state.flashlight_count,
1677
- state.dust_rings,
1678
- )
1679
-
1680
- _draw_hint_indicator(
1681
- screen,
1682
- camera,
1683
- assets,
1684
- player,
1685
- hint_target,
1686
- hint_color=hint_color,
1687
- stage=stage,
1688
- flashlight_count=flashlight_count,
1689
- )
1690
- _draw_fog_of_war(
1691
- screen,
1692
- camera,
1693
- assets,
1694
- fog_surfaces,
1695
- fov_target,
1696
- stage=stage,
1697
- flashlight_count=flashlight_count,
1698
- dawn_ready=state.dawn_ready,
1699
- )
1700
- _draw_need_fuel_message(
1701
- screen,
1702
- assets,
1703
- has_fuel=has_fuel,
1704
- fuel_message_until=fuel_message_until,
1705
- elapsed_play_ms=elapsed_play_ms,
1706
- )
1707
-
1708
- objective_lines = _build_objective_lines(
1709
- stage=stage,
1710
- state=state,
1711
- player=player,
1712
- active_car=active_car,
1713
- has_fuel=has_fuel,
1714
- buddy_onboard=buddy_onboard,
1715
- buddy_required=buddy_required,
1716
- survivors_onboard=survivors_onboard,
1717
- )
1718
- if objective_lines:
1719
- _draw_objective(objective_lines, screen=screen)
1720
- _draw_inventory_icons(
1721
- screen,
1722
- assets,
1723
- has_fuel=has_fuel,
1724
- flashlight_count=flashlight_count,
1725
- shoes_count=shoes_count,
1726
- )
1727
- _draw_survivor_messages(screen, assets, survivor_messages)
1728
- _draw_endurance_timer(screen, assets, stage=stage, state=state)
1729
- _draw_time_accel_indicator(screen, assets, stage=stage, state=state)
1730
- _draw_status_bar(
1731
- screen,
1732
- assets,
1733
- config,
1734
- stage=stage,
1735
- seed=state.seed,
1736
- debug_mode=state.debug_mode,
1737
- zombie_group=zombie_group,
1738
- falling_spawn_carry=state.falling_spawn_carry,
1739
- show_fps=state.show_fps,
1740
- fps=fps,
1741
- )
1742
- if do_flip:
1743
- if present_fn:
1744
- present_fn(screen)
1745
- else:
1746
- pygame.display.flip()