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
@@ -0,0 +1,992 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from enum import Enum
5
+ from typing import Any
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
+ FOOTPRINT_COLOR,
13
+ LIGHT_GRAY,
14
+ ORANGE,
15
+ WHITE,
16
+ YELLOW,
17
+ get_environment_palette,
18
+ )
19
+ from ..entities import (
20
+ Camera,
21
+ Player,
22
+ )
23
+ from ..entities_constants import INTERNAL_WALL_BEVEL_DEPTH, ZOMBIE_RADIUS
24
+ from ..font_utils import load_font
25
+ from ..gameplay_constants import DEFAULT_FLASHLIGHT_SPAWN_COUNT
26
+ from ..localization import get_font_settings
27
+ from ..localization import translate as tr
28
+ from ..models import DustRing, FallingZombie, Footprint, GameData, Stage
29
+ from ..render_assets import RenderAssets
30
+ from ..render_constants import (
31
+ ENTITY_SHADOW_ALPHA,
32
+ FALLING_DUST_COLOR,
33
+ FALLING_WHIRLWIND_COLOR,
34
+ FALLING_ZOMBIE_COLOR,
35
+ PITFALL_ABYSS_COLOR,
36
+ PITFALL_EDGE_DEPTH_OFFSET,
37
+ PITFALL_EDGE_METAL_COLOR,
38
+ PITFALL_EDGE_STRIPE_COLOR,
39
+ PITFALL_EDGE_STRIPE_SPACING,
40
+ PLAYER_SHADOW_ALPHA_MULT,
41
+ PLAYER_SHADOW_RADIUS_MULT,
42
+ )
43
+ from .hud import (
44
+ _build_objective_lines,
45
+ _draw_endurance_timer,
46
+ _draw_hint_indicator,
47
+ _draw_inventory_icons,
48
+ _draw_objective,
49
+ _draw_status_bar,
50
+ _draw_survivor_messages,
51
+ _draw_time_accel_indicator,
52
+ _get_fog_scale,
53
+ )
54
+ from .shadows import (
55
+ _draw_entity_shadows,
56
+ _draw_single_entity_shadow,
57
+ _draw_wall_shadows,
58
+ _get_shadow_layer,
59
+ )
60
+
61
+
62
+ def show_message(
63
+ screen: surface.Surface,
64
+ text: str,
65
+ size: int,
66
+ color: tuple[int, int, int],
67
+ position: tuple[int, int],
68
+ ) -> None:
69
+ try:
70
+ font_settings = get_font_settings()
71
+ font = load_font(font_settings.resource, font_settings.scaled_size(size))
72
+ text_surface = font.render(text, False, color)
73
+ text_rect = text_surface.get_rect(center=position)
74
+
75
+ # Add a semi-transparent background rectangle for better visibility
76
+ bg_padding = 15
77
+ bg_rect = text_rect.inflate(bg_padding * 2, bg_padding * 2)
78
+ bg_surface = pygame.Surface((bg_rect.width, bg_rect.height), pygame.SRCALPHA)
79
+ bg_surface.fill((0, 0, 0, 180)) # Black with 180 alpha (out of 255)
80
+ screen.blit(bg_surface, bg_rect.topleft)
81
+
82
+ screen.blit(text_surface, text_rect)
83
+ except pygame.error as e:
84
+ print(f"Error rendering font or surface: {e}")
85
+
86
+
87
+ def wrap_long_segment(segment: str, font: pygame.font.Font, max_width: int) -> list[str]:
88
+ lines: list[str] = []
89
+ current = ""
90
+ for char in segment:
91
+ candidate = current + char
92
+ if font.size(candidate)[0] <= max_width or not current:
93
+ current = candidate
94
+ else:
95
+ lines.append(current)
96
+ current = char
97
+ if current:
98
+ lines.append(current)
99
+ return lines
100
+
101
+
102
+ def wrap_text(text: str, font: pygame.font.Font, max_width: int) -> list[str]:
103
+ if max_width <= 0:
104
+ return [text]
105
+ paragraphs = text.splitlines() or [text]
106
+ lines: list[str] = []
107
+ for paragraph in paragraphs:
108
+ if not paragraph:
109
+ lines.append("")
110
+ continue
111
+ words = paragraph.split(" ")
112
+ if len(words) == 1:
113
+ lines.extend(wrap_long_segment(paragraph, font, max_width))
114
+ continue
115
+ current = ""
116
+ for word in words:
117
+ candidate = f"{current} {word}".strip() if current else word
118
+ if font.size(candidate)[0] <= max_width:
119
+ current = candidate
120
+ continue
121
+ if current:
122
+ lines.append(current)
123
+ if font.size(word)[0] <= max_width:
124
+ current = word
125
+ else:
126
+ lines.extend(wrap_long_segment(word, font, max_width))
127
+ current = ""
128
+ if current:
129
+ lines.append(current)
130
+ return lines
131
+
132
+
133
+ def blit_wrapped_text(
134
+ target: surface.Surface,
135
+ text: str,
136
+ font: pygame.font.Font,
137
+ color: tuple[int, int, int],
138
+ topleft: tuple[int, int],
139
+ max_width: int,
140
+ ) -> None:
141
+ """Render text with simple wrapping constrained to max_width."""
142
+
143
+ x, y = topleft
144
+ line_height = font.get_linesize()
145
+ for line in wrap_text(text, font, max_width):
146
+ if not line:
147
+ y += line_height
148
+ continue
149
+ rendered = font.render(line, False, color)
150
+ target.blit(rendered, (x, y))
151
+ y += line_height
152
+
153
+
154
+ def show_message_wrapped(
155
+ screen: surface.Surface,
156
+ text: str,
157
+ size: int,
158
+ color: tuple[int, int, int],
159
+ position: tuple[int, int],
160
+ *,
161
+ max_width: int,
162
+ line_spacing: int = 2,
163
+ ) -> None:
164
+ try:
165
+ font_settings = get_font_settings()
166
+ font = load_font(font_settings.resource, font_settings.scaled_size(size))
167
+ lines = wrap_text(text, font, max_width)
168
+ if not lines:
169
+ return
170
+ rendered = [font.render(line, False, color) for line in lines]
171
+ max_line_width = max(surface.get_width() for surface in rendered)
172
+ line_height = font.get_linesize()
173
+ total_height = line_height * len(rendered) + line_spacing * (len(rendered) - 1)
174
+
175
+ center_x, center_y = position
176
+ top = center_y - total_height // 2
177
+
178
+ bg_padding = 15
179
+ bg_width = max_line_width + bg_padding * 2
180
+ bg_height = total_height + bg_padding * 2
181
+ bg_rect = pygame.Rect(0, 0, bg_width, bg_height)
182
+ bg_rect.center = (center_x, center_y)
183
+ bg_surface = pygame.Surface((bg_rect.width, bg_rect.height), pygame.SRCALPHA)
184
+ bg_surface.fill((0, 0, 0, 180))
185
+ screen.blit(bg_surface, bg_rect.topleft)
186
+
187
+ y = top
188
+ for text_surface in rendered:
189
+ text_rect = text_surface.get_rect(centerx=center_x, y=y)
190
+ screen.blit(text_surface, text_rect)
191
+ y += line_height + line_spacing
192
+ except pygame.error as e:
193
+ print(f"Error rendering font or surface: {e}")
194
+
195
+
196
+ def draw_pause_overlay(screen: pygame.Surface) -> None:
197
+ screen_width, screen_height = screen.get_size()
198
+ overlay = pygame.Surface((screen_width, screen_height), pygame.SRCALPHA)
199
+ overlay.fill((0, 0, 0, 150))
200
+ pause_radius = 53
201
+ cx = screen_width // 2
202
+ cy = screen_height // 2 - 18
203
+ pygame.draw.circle(
204
+ overlay,
205
+ LIGHT_GRAY,
206
+ (cx, cy),
207
+ pause_radius,
208
+ width=3,
209
+ )
210
+ bar_width = 10
211
+ bar_height = 38
212
+ gap = 12
213
+ pygame.draw.rect(
214
+ overlay,
215
+ LIGHT_GRAY,
216
+ (cx - gap - bar_width, cy - bar_height // 2, bar_width, bar_height),
217
+ )
218
+ pygame.draw.rect(
219
+ overlay,
220
+ LIGHT_GRAY,
221
+ (cx + gap, cy - bar_height // 2, bar_width, bar_height),
222
+ )
223
+ screen.blit(overlay, (0, 0))
224
+ show_message(
225
+ screen,
226
+ tr("hud.paused"),
227
+ 18,
228
+ WHITE,
229
+ (screen_width // 2, 28),
230
+ )
231
+ show_message(
232
+ screen,
233
+ tr("hud.pause_hint"),
234
+ 16,
235
+ LIGHT_GRAY,
236
+ (screen_width // 2, screen_height // 2 + 70),
237
+ )
238
+
239
+
240
+
241
+
242
+ def _max_flashlight_pickups() -> int:
243
+ """Return the maximum flashlight pickups available per stage."""
244
+ return max(1, DEFAULT_FLASHLIGHT_SPAWN_COUNT)
245
+
246
+
247
+ class FogProfile(Enum):
248
+ DARK0 = (0, (0, 0, 0, 255))
249
+ DARK1 = (1, (0, 0, 0, 255))
250
+ DARK2 = (2, (0, 0, 0, 255))
251
+ DAWN = (2, (50, 50, 50, 230))
252
+
253
+ def __init__(self, flashlight_count: int, color: tuple[int, int, int, int]) -> None:
254
+ self.flashlight_count = flashlight_count
255
+ self.color = color
256
+
257
+ def _scale(self, assets: RenderAssets, stage: Stage | None) -> float:
258
+ count = max(0, min(self.flashlight_count, _max_flashlight_pickups()))
259
+ return _get_fog_scale(assets, count)
260
+
261
+ @staticmethod
262
+ def _from_flashlight_count(count: int) -> "FogProfile":
263
+ safe_count = max(0, count)
264
+ if safe_count >= 2:
265
+ return FogProfile.DARK2
266
+ if safe_count == 1:
267
+ return FogProfile.DARK1
268
+ return FogProfile.DARK0
269
+
270
+
271
+ def prewarm_fog_overlays(
272
+ fog_data: dict[str, Any],
273
+ assets: RenderAssets,
274
+ *,
275
+ stage: Stage | None = None,
276
+ ) -> None:
277
+ """Populate fog overlay cache for each reachable flashlight count."""
278
+
279
+ for profile in FogProfile:
280
+ _get_fog_overlay_surfaces(
281
+ fog_data,
282
+ assets,
283
+ profile,
284
+ stage=stage,
285
+ )
286
+
287
+
288
+ def _get_hatch_pattern(
289
+ fog_data: dict[str, Any],
290
+ thickness: int,
291
+ *,
292
+ color: tuple[int, int, int, int] | None = None,
293
+ ) -> surface.Surface:
294
+ """Return cached dot hatch tile surface (Bayer-ordered, optionally chunky)."""
295
+ cache = fog_data.setdefault("hatch_patterns", {})
296
+ key = (thickness, color)
297
+ if key in cache:
298
+ return cache[key]
299
+
300
+ spacing = 4
301
+ oversample = 3
302
+ density = max(1, min(thickness, 16))
303
+ pattern_size = spacing * 8
304
+ hi_spacing = spacing * oversample
305
+ hi_pattern_size = pattern_size * oversample
306
+ pattern = pygame.Surface((hi_pattern_size, hi_pattern_size), pygame.SRCALPHA)
307
+
308
+ # 8x8 Bayer matrix values 0..63 for ordered dithering
309
+ bayer = [
310
+ [0, 32, 8, 40, 2, 34, 10, 42],
311
+ [48, 16, 56, 24, 50, 18, 58, 26],
312
+ [12, 44, 4, 36, 14, 46, 6, 38],
313
+ [60, 28, 52, 20, 62, 30, 54, 22],
314
+ [3, 35, 11, 43, 1, 33, 9, 41],
315
+ [51, 19, 59, 27, 49, 17, 57, 25],
316
+ [15, 47, 7, 39, 13, 45, 5, 37],
317
+ [63, 31, 55, 23, 61, 29, 53, 21],
318
+ ]
319
+ threshold = int((density / 16) * 64)
320
+ dot_radius = max(
321
+ 1,
322
+ min(hi_spacing, int(math.ceil((density / 16) * hi_spacing))),
323
+ )
324
+ dot_color = color or (0, 0, 0, 255)
325
+ for grid_y in range(8):
326
+ for grid_x in range(8):
327
+ if bayer[grid_y][grid_x] < threshold:
328
+ cx = grid_x * hi_spacing + hi_spacing // 2
329
+ cy = grid_y * hi_spacing + hi_spacing // 2
330
+ pygame.draw.circle(pattern, dot_color, (cx, cy), dot_radius)
331
+
332
+ if oversample > 1:
333
+ pattern = pygame.transform.smoothscale(pattern, (pattern_size, pattern_size))
334
+
335
+ cache[key] = pattern
336
+ return pattern
337
+
338
+
339
+ def _get_fog_overlay_surfaces(
340
+ fog_data: dict[str, Any],
341
+ assets: RenderAssets,
342
+ profile: FogProfile,
343
+ *,
344
+ stage: Stage | None = None,
345
+ ) -> dict[str, Any]:
346
+ overlays = fog_data.setdefault("overlays", {})
347
+ key = profile
348
+ if key in overlays:
349
+ return overlays[key]
350
+
351
+ scale = profile._scale(assets, stage)
352
+ ring_scale = scale
353
+ if profile.flashlight_count >= 2:
354
+ ring_scale += max(0.0, assets.flashlight_hatch_extra_scale)
355
+ max_radius = int(assets.fov_radius * scale)
356
+ padding = 32
357
+ coverage_width = max(assets.screen_width * 2, max_radius * 2)
358
+ coverage_height = max(assets.screen_height * 2, max_radius * 2)
359
+ width = coverage_width + padding * 2
360
+ height = coverage_height + padding * 2
361
+ center = (width // 2, height // 2)
362
+
363
+ hard_surface = pygame.Surface((width, height), pygame.SRCALPHA)
364
+ base_color = profile.color
365
+ hard_surface.fill(base_color)
366
+ pygame.draw.circle(hard_surface, (0, 0, 0, 0), center, max_radius)
367
+
368
+ ring_surfaces: list[surface.Surface] = []
369
+ for ring in assets.fog_rings:
370
+ ring_surface = pygame.Surface((width, height), pygame.SRCALPHA)
371
+ pattern = _get_hatch_pattern(
372
+ fog_data,
373
+ ring.thickness,
374
+ color=base_color,
375
+ )
376
+ p_w, p_h = pattern.get_size()
377
+ for y in range(0, height, p_h):
378
+ for x in range(0, width, p_w):
379
+ ring_surface.blit(pattern, (x, y))
380
+ radius = int(assets.fov_radius * ring.radius_factor * ring_scale)
381
+ pygame.draw.circle(ring_surface, (0, 0, 0, 0), center, radius)
382
+ ring_surfaces.append(ring_surface)
383
+
384
+ combined_surface = hard_surface.copy()
385
+ for ring_surface in ring_surfaces:
386
+ combined_surface.blit(ring_surface, (0, 0))
387
+
388
+ visible_fade_surface = _build_flashlight_fade_surface((width, height), center, max_radius)
389
+ combined_surface.blit(visible_fade_surface, (0, 0))
390
+
391
+ overlay_entry = {
392
+ "hard": hard_surface,
393
+ "rings": ring_surfaces,
394
+ "combined": combined_surface,
395
+ }
396
+ overlays[key] = overlay_entry
397
+ return overlay_entry
398
+
399
+
400
+ def _build_flashlight_fade_surface(
401
+ size: tuple[int, int],
402
+ center: tuple[int, int],
403
+ max_radius: int,
404
+ *,
405
+ start_ratio: float = 0.2,
406
+ max_alpha: int = 220,
407
+ outer_extension: int = 30,
408
+ ) -> surface.Surface:
409
+ """Return a radial gradient so flashlight edges softly darken again."""
410
+
411
+ width, height = size
412
+ fade_surface = pygame.Surface((width, height), pygame.SRCALPHA)
413
+ fade_surface.fill((0, 0, 0, 0))
414
+
415
+ start_radius = max(0.0, min(max_radius, max_radius * start_ratio))
416
+ end_radius = max(start_radius + 1, max_radius + outer_extension)
417
+ fade_range = max(1.0, end_radius - start_radius)
418
+
419
+ alpha_view = None
420
+ if pg_surfarray is not None:
421
+ alpha_view = pg_surfarray.pixels_alpha(fade_surface)
422
+ else: # pragma: no cover - numpy-less fallback
423
+ fade_surface.lock()
424
+
425
+ cx, cy = center
426
+ for y in range(height):
427
+ dy = y - cy
428
+ for x in range(width):
429
+ dx = x - cx
430
+ dist = math.hypot(dx, dy)
431
+ if dist > end_radius:
432
+ dist = end_radius
433
+ if dist <= start_radius:
434
+ alpha = 0
435
+ else:
436
+ progress = min(1.0, (dist - start_radius) / fade_range)
437
+ alpha = int(max_alpha * progress)
438
+ if alpha <= 0:
439
+ continue
440
+ if alpha_view is not None:
441
+ alpha_view[x, y] = alpha
442
+ else:
443
+ fade_surface.set_at((x, y), (0, 0, 0, alpha))
444
+
445
+ if alpha_view is not None:
446
+ del alpha_view
447
+ else: # pragma: no cover
448
+ fade_surface.unlock()
449
+
450
+ return fade_surface
451
+
452
+
453
+ def _draw_fall_whirlwind(
454
+ screen: surface.Surface,
455
+ camera: Camera,
456
+ center: tuple[int, int],
457
+ progress: float,
458
+ *,
459
+ scale: float = 1.0,
460
+ ) -> None:
461
+ base_alpha = FALLING_WHIRLWIND_COLOR[3]
462
+ alpha = int(max(0, min(255, base_alpha * (1.0 - progress))))
463
+ if alpha <= 0:
464
+ return
465
+ color = (
466
+ FALLING_WHIRLWIND_COLOR[0],
467
+ FALLING_WHIRLWIND_COLOR[1],
468
+ FALLING_WHIRLWIND_COLOR[2],
469
+ alpha,
470
+ )
471
+ safe_scale = max(0.4, scale)
472
+ swirl_radius = max(2, int(ZOMBIE_RADIUS * 1.1 * safe_scale))
473
+ offset = max(1, int(ZOMBIE_RADIUS * 0.6 * safe_scale))
474
+ size = swirl_radius * 4
475
+ swirl = pygame.Surface((size, size), pygame.SRCALPHA)
476
+ cx = cy = size // 2
477
+ for idx in range(2):
478
+ angle = progress * math.tau * 0.3 + idx * (math.tau / 2)
479
+ ox = int(math.cos(angle) * offset)
480
+ oy = int(math.sin(angle) * offset)
481
+ pygame.draw.circle(swirl, color, (cx + ox, cy + oy), swirl_radius, width=2)
482
+ world_rect = pygame.Rect(0, 0, 1, 1)
483
+ world_rect.center = center
484
+ screen_center = camera.apply_rect(world_rect).center
485
+ screen.blit(swirl, swirl.get_rect(center=screen_center))
486
+
487
+
488
+ def _draw_falling_fx(
489
+ screen: surface.Surface,
490
+ camera: Camera,
491
+ falling_zombies: list[FallingZombie],
492
+ flashlight_count: int,
493
+ dust_rings: list[DustRing],
494
+ ) -> None:
495
+ if not falling_zombies and not dust_rings:
496
+ return
497
+ now = pygame.time.get_ticks()
498
+ for fall in falling_zombies:
499
+ pre_fx_ms = max(0, fall.pre_fx_ms)
500
+ fall_duration_ms = max(1, fall.fall_duration_ms)
501
+ fall_start = fall.started_at_ms + pre_fx_ms
502
+ impact_at = fall_start + fall_duration_ms
503
+ if now < fall_start:
504
+ if flashlight_count > 0 and pre_fx_ms > 0:
505
+ fx_progress = max(0.0, min(1.0, (now - fall.started_at_ms) / pre_fx_ms))
506
+ # Make the premonition grow with the impending drop scale.
507
+ pre_scale = 1.0 + (0.9 * fx_progress)
508
+ _draw_fall_whirlwind(
509
+ screen,
510
+ camera,
511
+ fall.start_pos,
512
+ fx_progress,
513
+ scale=pre_scale,
514
+ )
515
+ continue
516
+ if now >= impact_at:
517
+ continue
518
+ fall_progress = max(0.0, min(1.0, (now - fall_start) / fall_duration_ms))
519
+
520
+ if getattr(fall, "mode", "spawn") == "pitfall":
521
+ scale = 1.0 - fall_progress
522
+ scale = scale * scale
523
+ y_offset = 0.0
524
+ else:
525
+ eased = 1.0 - (1.0 - fall_progress) * (1.0 - fall_progress)
526
+ scale = 2.0 - (1.0 * eased)
527
+ # Add an extra vertical drop from above (1.5x wall depth)
528
+ y_offset = -INTERNAL_WALL_BEVEL_DEPTH * 1.5 * (1.0 - eased)
529
+
530
+ radius = ZOMBIE_RADIUS * scale
531
+ cx = fall.target_pos[0]
532
+ cy = fall.target_pos[1] + ZOMBIE_RADIUS - radius + y_offset
533
+
534
+ world_rect = pygame.Rect(0, 0, radius * 2, radius * 2)
535
+ world_rect.center = (int(cx), int(cy))
536
+ screen_rect = camera.apply_rect(world_rect)
537
+ pygame.draw.circle(
538
+ screen,
539
+ FALLING_ZOMBIE_COLOR,
540
+ screen_rect.center,
541
+ max(1, int(screen_rect.width / 2)),
542
+ )
543
+
544
+ for ring in list(dust_rings):
545
+ elapsed = now - ring.started_at_ms
546
+ if elapsed >= ring.duration_ms:
547
+ dust_rings.remove(ring)
548
+ continue
549
+ progress = max(0.0, min(1.0, elapsed / ring.duration_ms))
550
+ alpha = int(max(0, min(255, FALLING_DUST_COLOR[3] * (1.0 - progress))))
551
+ if alpha <= 0:
552
+ continue
553
+ radius = int(ZOMBIE_RADIUS * (0.7 + progress * 1.9))
554
+ color = (
555
+ FALLING_DUST_COLOR[0],
556
+ FALLING_DUST_COLOR[1],
557
+ FALLING_DUST_COLOR[2],
558
+ alpha,
559
+ )
560
+ world_rect = pygame.Rect(0, 0, 1, 1)
561
+ world_rect.center = ring.pos
562
+ screen_center = camera.apply_rect(world_rect).center
563
+ pygame.draw.circle(screen, color, screen_center, radius, width=2)
564
+
565
+
566
+ def _draw_play_area(
567
+ screen: surface.Surface,
568
+ camera: Camera,
569
+ assets: RenderAssets,
570
+ palette: Any,
571
+ field_rect: pygame.Rect,
572
+ outside_cells: set[tuple[int, int]],
573
+ fall_spawn_cells: set[tuple[int, int]],
574
+ pitfall_cells: set[tuple[int, int]],
575
+ ) -> tuple[int, int, int, int, set[tuple[int, int]]]:
576
+ grid_snap = assets.internal_wall_grid_snap
577
+ xs, ys, xe, ye = (
578
+ field_rect.left,
579
+ field_rect.top,
580
+ field_rect.right,
581
+ field_rect.bottom,
582
+ )
583
+ xs //= grid_snap
584
+ ys //= grid_snap
585
+ xe //= grid_snap
586
+ ye //= grid_snap
587
+
588
+ play_area_rect = pygame.Rect(
589
+ xs * grid_snap,
590
+ ys * grid_snap,
591
+ (xe - xs) * grid_snap,
592
+ (ye - ys) * grid_snap,
593
+ )
594
+ play_area_screen_rect = camera.apply_rect(play_area_rect)
595
+ pygame.draw.rect(screen, palette.floor_primary, play_area_screen_rect)
596
+
597
+ view_world = pygame.Rect(
598
+ -camera.camera.x,
599
+ -camera.camera.y,
600
+ assets.screen_width,
601
+ assets.screen_height,
602
+ )
603
+ margin = grid_snap * 2
604
+ view_world.inflate_ip(margin * 2, margin * 2)
605
+ min_world_x = max(xs * grid_snap, view_world.left)
606
+ max_world_x = min(xe * grid_snap, view_world.right)
607
+ min_world_y = max(ys * grid_snap, view_world.top)
608
+ max_world_y = min(ye * grid_snap, view_world.bottom)
609
+ start_x = max(xs, int(min_world_x // grid_snap))
610
+ end_x = min(xe, int(math.ceil(max_world_x / grid_snap)))
611
+ start_y = max(ys, int(min_world_y // grid_snap))
612
+ end_y = min(ye, int(math.ceil(max_world_y / grid_snap)))
613
+
614
+ for y in range(start_y, end_y):
615
+ for x in range(start_x, end_x):
616
+ if (x, y) in outside_cells:
617
+ lx, ly = (
618
+ x * grid_snap,
619
+ y * grid_snap,
620
+ )
621
+ r = pygame.Rect(
622
+ lx,
623
+ ly,
624
+ grid_snap,
625
+ grid_snap,
626
+ )
627
+ sr = camera.apply_rect(r)
628
+ if sr.colliderect(screen.get_rect()):
629
+ pygame.draw.rect(screen, palette.outside, sr)
630
+ continue
631
+
632
+ if (x, y) in pitfall_cells:
633
+ lx, ly = (
634
+ x * grid_snap,
635
+ y * grid_snap,
636
+ )
637
+ r = pygame.Rect(
638
+ lx,
639
+ ly,
640
+ grid_snap,
641
+ grid_snap,
642
+ )
643
+ sr = camera.apply_rect(r)
644
+ if not sr.colliderect(screen.get_rect()):
645
+ continue
646
+ pygame.draw.rect(screen, PITFALL_ABYSS_COLOR, sr)
647
+
648
+ if (x, y - 1) not in pitfall_cells:
649
+ edge_h = max(1, INTERNAL_WALL_BEVEL_DEPTH - PITFALL_EDGE_DEPTH_OFFSET)
650
+ pygame.draw.rect(screen, PITFALL_EDGE_METAL_COLOR, (sr.x, sr.y, sr.w, edge_h))
651
+ for sx in range(sr.x - edge_h, sr.right, PITFALL_EDGE_STRIPE_SPACING):
652
+ pygame.draw.line(
653
+ screen,
654
+ PITFALL_EDGE_STRIPE_COLOR,
655
+ (max(sr.x, sx), sr.y),
656
+ (min(sr.right - 1, sx + edge_h), sr.y + edge_h - 1),
657
+ width=2,
658
+ )
659
+
660
+ continue
661
+
662
+ use_secondary = ((x // 2) + (y // 2)) % 2 == 0
663
+ if (x, y) in fall_spawn_cells:
664
+ color = palette.fall_zone_secondary if use_secondary else palette.fall_zone_primary
665
+ elif not use_secondary:
666
+ continue
667
+ else:
668
+ color = palette.floor_secondary
669
+ lx, ly = (
670
+ x * grid_snap,
671
+ y * grid_snap,
672
+ )
673
+ r = pygame.Rect(
674
+ lx,
675
+ ly,
676
+ grid_snap,
677
+ grid_snap,
678
+ )
679
+ sr = camera.apply_rect(r)
680
+ if sr.colliderect(screen.get_rect()):
681
+ pygame.draw.rect(screen, color, sr)
682
+
683
+ return xs, ys, xe, ye, outside_cells
684
+
685
+
686
+ def _draw_footprints(
687
+ screen: surface.Surface,
688
+ camera: Camera,
689
+ assets: RenderAssets,
690
+ footprints: list[Footprint],
691
+ *,
692
+ config: dict[str, Any],
693
+ ) -> None:
694
+ if not config.get("footprints", {}).get("enabled", True):
695
+ return
696
+ now = pygame.time.get_ticks()
697
+ for fp in footprints:
698
+ if not fp.visible:
699
+ continue
700
+ age = now - fp.time
701
+ fade = 1 - (age / assets.footprint_lifetime_ms)
702
+ fade = max(assets.footprint_min_fade, fade)
703
+ color = tuple(int(c * fade) for c in FOOTPRINT_COLOR)
704
+ fp_rect = pygame.Rect(
705
+ fp.pos[0] - assets.footprint_radius,
706
+ fp.pos[1] - assets.footprint_radius,
707
+ assets.footprint_radius * 2,
708
+ assets.footprint_radius * 2,
709
+ )
710
+ sr = camera.apply_rect(fp_rect)
711
+ if sr.colliderect(screen.get_rect().inflate(30, 30)):
712
+ pygame.draw.circle(screen, color, sr.center, assets.footprint_radius)
713
+
714
+
715
+ def _draw_entities(
716
+ screen: surface.Surface,
717
+ camera: Camera,
718
+ all_sprites: sprite.LayeredUpdates,
719
+ player: Player,
720
+ *,
721
+ has_fuel: bool,
722
+ ) -> pygame.Rect:
723
+ screen_rect_inflated = screen.get_rect().inflate(100, 100)
724
+ player_screen_rect: pygame.Rect | None = None
725
+ for entity in all_sprites:
726
+ sprite_screen_rect = camera.apply_rect(entity.rect)
727
+ if sprite_screen_rect.colliderect(screen_rect_inflated):
728
+ screen.blit(entity.image, sprite_screen_rect)
729
+ if entity is player:
730
+ player_screen_rect = sprite_screen_rect
731
+ _draw_fuel_indicator(
732
+ screen,
733
+ player_screen_rect,
734
+ has_fuel=has_fuel,
735
+ in_car=player.in_car,
736
+ )
737
+ return player_screen_rect or camera.apply_rect(player.rect)
738
+
739
+
740
+ def _draw_fuel_indicator(
741
+ screen: surface.Surface,
742
+ player_screen_rect: pygame.Rect,
743
+ *,
744
+ has_fuel: bool,
745
+ in_car: bool,
746
+ ) -> None:
747
+ if not has_fuel or in_car:
748
+ return
749
+ indicator_size = 4
750
+ padding = 1
751
+ indicator_rect = pygame.Rect(
752
+ player_screen_rect.right - indicator_size - padding,
753
+ player_screen_rect.bottom - indicator_size - padding,
754
+ indicator_size,
755
+ indicator_size,
756
+ )
757
+ pygame.draw.rect(screen, YELLOW, indicator_rect)
758
+ pygame.draw.rect(screen, (180, 160, 40), indicator_rect, width=1)
759
+
760
+
761
+ def _draw_fog_of_war(
762
+ screen: surface.Surface,
763
+ camera: Camera,
764
+ assets: RenderAssets,
765
+ fog_surfaces: dict[str, Any],
766
+ fov_target: pygame.sprite.Sprite | None,
767
+ *,
768
+ stage: Stage | None,
769
+ flashlight_count: int,
770
+ dawn_ready: bool,
771
+ ) -> None:
772
+ if fov_target is None:
773
+ return
774
+ fov_center_on_screen = list(camera.apply(fov_target).center)
775
+ cam_rect = camera.camera
776
+ horizontal_span = camera.width - assets.screen_width
777
+ vertical_span = camera.height - assets.screen_height
778
+ if horizontal_span <= 0 or (cam_rect.x != 0 and cam_rect.x != -horizontal_span):
779
+ fov_center_on_screen[0] = assets.screen_width // 2
780
+ if vertical_span <= 0 or (cam_rect.y != 0 and cam_rect.y != -vertical_span):
781
+ fov_center_on_screen[1] = assets.screen_height // 2
782
+ fov_center_tuple = (int(fov_center_on_screen[0]), int(fov_center_on_screen[1]))
783
+ if dawn_ready:
784
+ profile = FogProfile.DAWN
785
+ else:
786
+ profile = FogProfile._from_flashlight_count(flashlight_count)
787
+ overlay = _get_fog_overlay_surfaces(
788
+ fog_surfaces,
789
+ assets,
790
+ profile,
791
+ stage=stage,
792
+ )
793
+ combined_surface: surface.Surface = overlay["combined"]
794
+ screen.blit(
795
+ combined_surface,
796
+ combined_surface.get_rect(center=fov_center_tuple),
797
+ )
798
+
799
+
800
+ def _draw_need_fuel_message(
801
+ screen: surface.Surface,
802
+ assets: RenderAssets,
803
+ *,
804
+ has_fuel: bool,
805
+ fuel_message_until: int,
806
+ elapsed_play_ms: int,
807
+ ) -> None:
808
+ if has_fuel or fuel_message_until <= elapsed_play_ms:
809
+ return
810
+ show_message(
811
+ screen,
812
+ tr("hud.need_fuel"),
813
+ 18,
814
+ ORANGE,
815
+ (assets.screen_width // 2, assets.screen_height // 2),
816
+ )
817
+
818
+
819
+ def draw(
820
+ assets: RenderAssets,
821
+ screen: surface.Surface,
822
+ game_data: GameData,
823
+ *,
824
+ config: dict[str, Any],
825
+ hint_target: tuple[int, int] | None = None,
826
+ hint_color: tuple[int, int, int] | None = None,
827
+ fps: float | None = None,
828
+ ) -> None:
829
+ hint_color = hint_color or YELLOW
830
+ state = game_data.state
831
+ player = game_data.player
832
+ if player is None:
833
+ raise ValueError("draw requires an active player on game_data")
834
+
835
+ camera = game_data.camera
836
+ stage = game_data.stage
837
+ outside_cells = game_data.layout.outside_cells
838
+ all_sprites = game_data.groups.all_sprites
839
+ has_fuel = state.has_fuel
840
+ flashlight_count = state.flashlight_count
841
+ active_car = game_data.car if game_data.car and game_data.car.alive() else None
842
+ if player.in_car and game_data.car and game_data.car.alive():
843
+ fov_target = game_data.car
844
+ else:
845
+ fov_target = player
846
+
847
+ palette = get_environment_palette(state.ambient_palette_key)
848
+ screen.fill(palette.outside)
849
+
850
+ _draw_play_area(
851
+ screen,
852
+ camera,
853
+ assets,
854
+ palette,
855
+ game_data.layout.field_rect,
856
+ outside_cells,
857
+ game_data.layout.fall_spawn_cells,
858
+ game_data.layout.pitfall_cells,
859
+ )
860
+ shadow_layer = _get_shadow_layer(screen.get_size())
861
+ shadow_layer.fill((0, 0, 0, 0))
862
+ drew_shadow = _draw_wall_shadows(
863
+ shadow_layer,
864
+ camera,
865
+ wall_cells=game_data.layout.wall_cells,
866
+ wall_group=game_data.groups.wall_group,
867
+ outer_wall_cells=game_data.layout.outer_wall_cells,
868
+ cell_size=game_data.cell_size,
869
+ light_source_pos=(None if (stage and stage.endurance_stage and state.dawn_ready) else fov_target.rect.center)
870
+ if fov_target
871
+ else None,
872
+ )
873
+ drew_shadow |= _draw_entity_shadows(
874
+ shadow_layer,
875
+ camera,
876
+ all_sprites,
877
+ light_source_pos=fov_target.rect.center if fov_target else None,
878
+ exclude_car=active_car if player.in_car else None,
879
+ outside_cells=outside_cells,
880
+ cell_size=game_data.cell_size,
881
+ )
882
+ player_shadow_alpha = max(1, int(ENTITY_SHADOW_ALPHA * PLAYER_SHADOW_ALPHA_MULT))
883
+ player_shadow_radius = int(ZOMBIE_RADIUS * PLAYER_SHADOW_RADIUS_MULT)
884
+ if player.in_car:
885
+ drew_shadow |= _draw_single_entity_shadow(
886
+ shadow_layer,
887
+ camera,
888
+ entity=active_car,
889
+ light_source_pos=fov_target.rect.center if fov_target else None,
890
+ outside_cells=outside_cells,
891
+ cell_size=game_data.cell_size,
892
+ shadow_radius=player_shadow_radius,
893
+ alpha=player_shadow_alpha,
894
+ )
895
+ else:
896
+ drew_shadow |= _draw_single_entity_shadow(
897
+ shadow_layer,
898
+ camera,
899
+ entity=player,
900
+ light_source_pos=fov_target.rect.center if fov_target else None,
901
+ outside_cells=outside_cells,
902
+ cell_size=game_data.cell_size,
903
+ shadow_radius=player_shadow_radius,
904
+ alpha=player_shadow_alpha,
905
+ )
906
+ if drew_shadow:
907
+ screen.blit(shadow_layer, (0, 0))
908
+ _draw_footprints(
909
+ screen,
910
+ camera,
911
+ assets,
912
+ state.footprints,
913
+ config=config,
914
+ )
915
+ _draw_entities(
916
+ screen,
917
+ camera,
918
+ all_sprites,
919
+ player,
920
+ has_fuel=has_fuel,
921
+ )
922
+
923
+ _draw_falling_fx(
924
+ screen,
925
+ camera,
926
+ state.falling_zombies,
927
+ state.flashlight_count,
928
+ state.dust_rings,
929
+ )
930
+
931
+ _draw_hint_indicator(
932
+ screen,
933
+ camera,
934
+ assets,
935
+ player,
936
+ hint_target,
937
+ hint_color=hint_color,
938
+ stage=stage,
939
+ flashlight_count=flashlight_count,
940
+ )
941
+ _draw_fog_of_war(
942
+ screen,
943
+ camera,
944
+ assets,
945
+ game_data.fog,
946
+ fov_target,
947
+ stage=stage,
948
+ flashlight_count=flashlight_count,
949
+ dawn_ready=state.dawn_ready,
950
+ )
951
+ _draw_need_fuel_message(
952
+ screen,
953
+ assets,
954
+ has_fuel=has_fuel,
955
+ fuel_message_until=state.fuel_message_until,
956
+ elapsed_play_ms=state.elapsed_play_ms,
957
+ )
958
+
959
+ objective_lines = _build_objective_lines(
960
+ stage=stage,
961
+ state=state,
962
+ player=player,
963
+ active_car=active_car,
964
+ has_fuel=has_fuel,
965
+ buddy_onboard=state.buddy_onboard,
966
+ buddy_required=stage.buddy_required_count if stage else 0,
967
+ survivors_onboard=state.survivors_onboard,
968
+ )
969
+ if objective_lines:
970
+ _draw_objective(objective_lines, screen=screen)
971
+ _draw_inventory_icons(
972
+ screen,
973
+ assets,
974
+ has_fuel=has_fuel,
975
+ flashlight_count=flashlight_count,
976
+ shoes_count=state.shoes_count,
977
+ )
978
+ _draw_survivor_messages(screen, assets, list(state.survivor_messages))
979
+ _draw_endurance_timer(screen, assets, stage=stage, state=state)
980
+ _draw_time_accel_indicator(screen, assets, stage=stage, state=state)
981
+ _draw_status_bar(
982
+ screen,
983
+ assets,
984
+ config,
985
+ stage=stage,
986
+ seed=state.seed,
987
+ debug_mode=state.debug_mode,
988
+ zombie_group=game_data.groups.zombie_group,
989
+ falling_spawn_carry=state.falling_spawn_carry,
990
+ show_fps=state.show_fps,
991
+ fps=fps,
992
+ )