zombie-escape 1.5.4__py3-none-any.whl → 1.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/entities.py +501 -537
  3. zombie_escape/entities_constants.py +102 -0
  4. zombie_escape/gameplay/__init__.py +75 -2
  5. zombie_escape/gameplay/ambient.py +50 -0
  6. zombie_escape/gameplay/constants.py +46 -0
  7. zombie_escape/gameplay/footprints.py +60 -0
  8. zombie_escape/gameplay/interactions.py +354 -0
  9. zombie_escape/gameplay/layout.py +190 -0
  10. zombie_escape/gameplay/movement.py +220 -0
  11. zombie_escape/gameplay/spawn.py +618 -0
  12. zombie_escape/gameplay/state.py +137 -0
  13. zombie_escape/gameplay/survivors.py +306 -0
  14. zombie_escape/gameplay/utils.py +147 -0
  15. zombie_escape/gameplay_constants.py +0 -148
  16. zombie_escape/level_blueprints.py +123 -10
  17. zombie_escape/level_constants.py +6 -13
  18. zombie_escape/locales/ui.en.json +10 -1
  19. zombie_escape/locales/ui.ja.json +10 -1
  20. zombie_escape/models.py +15 -9
  21. zombie_escape/render.py +42 -27
  22. zombie_escape/render_assets.py +533 -23
  23. zombie_escape/render_constants.py +57 -22
  24. zombie_escape/rng.py +9 -9
  25. zombie_escape/screens/__init__.py +59 -29
  26. zombie_escape/screens/game_over.py +3 -3
  27. zombie_escape/screens/gameplay.py +45 -27
  28. zombie_escape/screens/title.py +5 -2
  29. zombie_escape/stage_constants.py +34 -1
  30. zombie_escape/zombie_escape.py +30 -12
  31. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/METADATA +1 -1
  32. zombie_escape-1.7.1.dist-info/RECORD +45 -0
  33. zombie_escape/gameplay/logic.py +0 -1917
  34. zombie_escape-1.5.4.dist-info/RECORD +0 -35
  35. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/WHEEL +0 -0
  36. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/entry_points.txt +0 -0
  37. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -1,1917 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from bisect import bisect_left
4
- from typing import Any, Mapping, Sequence
5
-
6
- import math
7
-
8
- import pygame
9
-
10
- from ..colors import (
11
- DAWN_AMBIENT_PALETTE_KEY,
12
- ambient_palette_key_for_flashlights,
13
- get_environment_palette,
14
- )
15
- from ..gameplay_constants import (
16
- CAR_HEIGHT,
17
- CAR_SPEED,
18
- CAR_WIDTH,
19
- CAR_ZOMBIE_DAMAGE,
20
- BUDDY_RADIUS,
21
- DEFAULT_FLASHLIGHT_SPAWN_COUNT,
22
- FAST_ZOMBIE_BASE_SPEED,
23
- FLASHLIGHT_HEIGHT,
24
- FLASHLIGHT_WIDTH,
25
- FOOTPRINT_MAX,
26
- FOOTPRINT_STEP_DISTANCE,
27
- FUEL_CAN_HEIGHT,
28
- FUEL_CAN_WIDTH,
29
- FUEL_HINT_DURATION_MS,
30
- INTERNAL_WALL_HEALTH,
31
- MAX_ZOMBIES,
32
- OUTER_WALL_HEALTH,
33
- PLAYER_RADIUS,
34
- PLAYER_SPEED,
35
- STEEL_BEAM_HEALTH,
36
- SURVIVOR_CONVERSION_LINE_KEYS,
37
- SURVIVOR_APPROACH_RADIUS,
38
- SURVIVOR_MAX_SAFE_PASSENGERS,
39
- SURVIVOR_MESSAGE_DURATION_MS,
40
- SURVIVOR_MIN_SPEED_FACTOR,
41
- SURVIVOR_OVERLOAD_DAMAGE_RATIO,
42
- SURVIVOR_RADIUS,
43
- SURVIVOR_SPEED_PENALTY_PER_PASSENGER,
44
- SURVIVOR_STAGE_WAITING_CAR_COUNT,
45
- SURVIVAL_NEAR_SPAWN_CAMERA_MARGIN,
46
- SURVIVAL_NEAR_SPAWN_MAX_DISTANCE,
47
- SURVIVAL_NEAR_SPAWN_MIN_DISTANCE,
48
- ZOMBIE_AGING_DURATION_FRAMES,
49
- ZOMBIE_RADIUS,
50
- ZOMBIE_SEPARATION_DISTANCE,
51
- ZOMBIE_SPAWN_DELAY_MS,
52
- ZOMBIE_SPAWN_PLAYER_BUFFER,
53
- ZOMBIE_SPEED,
54
- ZOMBIE_TRACKER_AGING_DURATION_FRAMES,
55
- interaction_radius,
56
- ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
57
- )
58
- from ..level_constants import CELL_SIZE, GRID_COLS, GRID_ROWS, LEVEL_HEIGHT, LEVEL_WIDTH
59
- from ..screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
60
- from ..localization import translate as tr
61
- from ..level_blueprints import choose_blueprint
62
- from ..models import Areas, GameData, Groups, ProgressState, Stage
63
- from ..rng import get_rng
64
- from ..entities import (
65
- Camera,
66
- Car,
67
- Flashlight,
68
- FuelCan,
69
- Player,
70
- SteelBeam,
71
- Survivor,
72
- Wall,
73
- Zombie,
74
- spritecollideany_walls,
75
- walls_for_radius,
76
- WallIndex,
77
- )
78
-
79
- LOGICAL_SCREEN_RECT = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
80
- RNG = get_rng()
81
-
82
-
83
- def car_appearance_for_stage(stage: Stage | None) -> str:
84
- return "disabled" if stage and stage.survival_stage else "default"
85
-
86
- __all__ = [
87
- "create_zombie",
88
- "rect_for_cell",
89
- "generate_level_from_blueprint",
90
- "place_new_car",
91
- "place_fuel_can",
92
- "place_flashlight",
93
- "place_flashlights",
94
- "place_buddies",
95
- "scatter_positions_on_walkable",
96
- "spawn_survivors",
97
- "spawn_nearby_zombie",
98
- "spawn_exterior_zombie",
99
- "spawn_weighted_zombie",
100
- "update_survivors",
101
- "alive_waiting_cars",
102
- "log_waiting_car_count",
103
- "nearest_waiting_car",
104
- "calculate_car_speed_for_passengers",
105
- "apply_passenger_speed_penalty",
106
- "increase_survivor_capacity",
107
- "waiting_car_target_count",
108
- "spawn_waiting_car",
109
- "maintain_waiting_car_supply",
110
- "add_survivor_message",
111
- "random_survivor_conversion_line",
112
- "cleanup_survivor_messages",
113
- "drop_survivors_from_car",
114
- "handle_survivor_zombie_collisions",
115
- "respawn_buddies_near_player",
116
- "get_shrunk_sprite",
117
- "update_footprints",
118
- "initialize_game_state",
119
- "setup_player_and_cars",
120
- "spawn_initial_zombies",
121
- "update_survival_timer",
122
- "carbonize_outdoor_zombies",
123
- "process_player_input",
124
- "update_entities",
125
- "check_interactions",
126
- "set_ambient_palette",
127
- "sync_ambient_palette_with_flashlights",
128
- ]
129
-
130
-
131
- def create_zombie(
132
- config: dict[str, Any],
133
- *,
134
- start_pos: tuple[int, int] | None = None,
135
- hint_pos: tuple[float, float] | None = None,
136
- stage: Stage | None = None,
137
- outer_wall_cells: set[tuple[int, int]] | None = None,
138
- tracker: bool | None = None,
139
- wall_follower: bool | None = None,
140
- ) -> Zombie:
141
- """Factory to create zombies with optional fast variants."""
142
- fast_conf = config.get("fast_zombies", {})
143
- fast_enabled = fast_conf.get("enabled", True)
144
- if fast_enabled:
145
- base_speed = RNG.uniform(ZOMBIE_SPEED, FAST_ZOMBIE_BASE_SPEED)
146
- else:
147
- base_speed = ZOMBIE_SPEED
148
- base_speed = min(base_speed, PLAYER_SPEED - 0.05)
149
- normal_ratio = 1.0
150
- tracker_ratio = 0.0
151
- wall_follower_ratio = 0.0
152
- if stage is not None:
153
- normal_ratio = max(0.0, min(1.0, getattr(stage, "zombie_normal_ratio", 1.0)))
154
- tracker_ratio = max(0.0, min(1.0, getattr(stage, "zombie_tracker_ratio", 0.0)))
155
- wall_follower_ratio = max(
156
- 0.0, min(1.0, getattr(stage, "zombie_wall_follower_ratio", 0.0))
157
- )
158
- if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
159
- # Fall back to normal behavior if all ratios are zero.
160
- normal_ratio = 1.0
161
- tracker_ratio = 0.0
162
- wall_follower_ratio = 0.0
163
- if (
164
- normal_ratio == 1.0
165
- and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
166
- and tracker_ratio + wall_follower_ratio <= 1.0
167
- ):
168
- normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
169
- aging_duration_frames = max(
170
- 1.0,
171
- float(
172
- getattr(
173
- stage, "zombie_aging_duration_frames", ZOMBIE_AGING_DURATION_FRAMES
174
- )
175
- ),
176
- )
177
- else:
178
- aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
179
- picked_tracker = False
180
- picked_wall_follower = False
181
- total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
182
- if total_ratio > 0:
183
- pick = RNG.random() * total_ratio
184
- if pick < normal_ratio:
185
- pass
186
- elif pick < normal_ratio + tracker_ratio:
187
- picked_tracker = True
188
- else:
189
- picked_wall_follower = True
190
- if tracker is None:
191
- tracker = picked_tracker
192
- if wall_follower is None:
193
- wall_follower = picked_wall_follower
194
- if tracker:
195
- wall_follower = False
196
- if tracker:
197
- ratio = (
198
- ZOMBIE_TRACKER_AGING_DURATION_FRAMES / ZOMBIE_AGING_DURATION_FRAMES
199
- if ZOMBIE_AGING_DURATION_FRAMES > 0
200
- else 1.0
201
- )
202
- aging_duration_frames = max(1.0, aging_duration_frames * ratio)
203
- return Zombie(
204
- start_pos=start_pos,
205
- hint_pos=hint_pos,
206
- speed=base_speed,
207
- tracker=tracker,
208
- wall_follower=wall_follower,
209
- aging_duration_frames=aging_duration_frames,
210
- outer_wall_cells=outer_wall_cells,
211
- )
212
-
213
-
214
- def rect_for_cell(x_idx: int, y_idx: int) -> pygame.Rect:
215
- return pygame.Rect(x_idx * CELL_SIZE, y_idx * CELL_SIZE, CELL_SIZE, CELL_SIZE)
216
-
217
-
218
- def generate_level_from_blueprint(
219
- game_data: GameData, config: dict[str, Any]
220
- ) -> dict[str, list[pygame.Rect]]:
221
- """Build walls/spawn candidates/outside area from a blueprint grid."""
222
- wall_group = game_data.groups.wall_group
223
- all_sprites = game_data.groups.all_sprites
224
-
225
- steel_conf = config.get("steel_beams", {})
226
- steel_enabled = steel_conf.get("enabled", False)
227
-
228
- blueprint_data = choose_blueprint(config)
229
- if isinstance(blueprint_data, dict):
230
- blueprint = blueprint_data.get("grid", [])
231
- steel_cells_raw = blueprint_data.get("steel_cells", set())
232
- else:
233
- blueprint = blueprint_data
234
- steel_cells_raw = set()
235
-
236
- steel_cells = (
237
- {(int(x), int(y)) for x, y in steel_cells_raw} if steel_enabled else set()
238
- )
239
- outer_wall_cells = {
240
- (x, y)
241
- for y, row in enumerate(blueprint)
242
- for x, ch in enumerate(row)
243
- if ch == "B"
244
- }
245
- wall_cells = {
246
- (x, y)
247
- for y, row in enumerate(blueprint)
248
- for x, ch in enumerate(row)
249
- if ch in {"B", "1"}
250
- }
251
-
252
- def has_wall(nx: int, ny: int) -> bool:
253
- if nx < 0 or ny < 0 or nx >= GRID_COLS or ny >= GRID_ROWS:
254
- return True
255
- return (nx, ny) in wall_cells
256
-
257
- outside_rects: list[pygame.Rect] = []
258
- walkable_cells: list[pygame.Rect] = []
259
- player_cells: list[pygame.Rect] = []
260
- car_cells: list[pygame.Rect] = []
261
- zombie_cells: list[pygame.Rect] = []
262
- palette = get_environment_palette(game_data.state.ambient_palette_key)
263
-
264
- def add_beam_to_groups(beam: "SteelBeam") -> None:
265
- if getattr(beam, "_added_to_groups", False):
266
- return
267
- wall_group.add(beam)
268
- all_sprites.add(beam, layer=0)
269
- beam._added_to_groups = True
270
-
271
- for y, row in enumerate(blueprint):
272
- if len(row) != GRID_COLS:
273
- raise ValueError(
274
- f"Blueprint width mismatch at row {y}: {len(row)} != {GRID_COLS}"
275
- )
276
- for x, ch in enumerate(row):
277
- cell_rect = rect_for_cell(x, y)
278
- cell_has_beam = steel_enabled and (x, y) in steel_cells
279
- if ch == "O":
280
- outside_rects.append(cell_rect)
281
- continue
282
- if ch == "B":
283
- draw_bottom_side = not has_wall(x, y + 1)
284
- wall = Wall(
285
- cell_rect.x,
286
- cell_rect.y,
287
- cell_rect.width,
288
- cell_rect.height,
289
- health=OUTER_WALL_HEALTH,
290
- color=palette.outer_wall,
291
- border_color=palette.outer_wall_border,
292
- palette_category="outer_wall",
293
- bevel_depth=0,
294
- draw_bottom_side=draw_bottom_side,
295
- )
296
- wall_group.add(wall)
297
- all_sprites.add(wall, layer=0)
298
- continue
299
- if ch == "E":
300
- if not cell_has_beam:
301
- walkable_cells.append(cell_rect)
302
- elif ch == "1":
303
- beam = None
304
- if cell_has_beam:
305
- beam = SteelBeam(
306
- cell_rect.x,
307
- cell_rect.y,
308
- cell_rect.width,
309
- health=STEEL_BEAM_HEALTH,
310
- )
311
- draw_bottom_side = not has_wall(x, y + 1)
312
- bevel_mask = (
313
- not has_wall(x, y - 1)
314
- and not has_wall(x - 1, y)
315
- and not has_wall(x - 1, y - 1),
316
- not has_wall(x, y - 1)
317
- and not has_wall(x + 1, y)
318
- and not has_wall(x + 1, y - 1),
319
- not has_wall(x, y + 1)
320
- and not has_wall(x + 1, y)
321
- and not has_wall(x + 1, y + 1),
322
- not has_wall(x, y + 1)
323
- and not has_wall(x - 1, y)
324
- and not has_wall(x - 1, y + 1),
325
- )
326
- wall = Wall(
327
- cell_rect.x,
328
- cell_rect.y,
329
- cell_rect.width,
330
- cell_rect.height,
331
- health=INTERNAL_WALL_HEALTH,
332
- color=palette.inner_wall,
333
- border_color=palette.inner_wall_border,
334
- palette_category="inner_wall",
335
- bevel_mask=bevel_mask,
336
- draw_bottom_side=draw_bottom_side,
337
- on_destroy=(lambda _w, b=beam: add_beam_to_groups(b))
338
- if beam
339
- else None,
340
- )
341
- wall_group.add(wall)
342
- all_sprites.add(wall, layer=0)
343
- # Embedded beams stay hidden until the wall is destroyed
344
- else:
345
- if not cell_has_beam:
346
- walkable_cells.append(cell_rect)
347
-
348
- if ch == "P":
349
- player_cells.append(cell_rect)
350
- if ch == "C":
351
- car_cells.append(cell_rect)
352
- if ch == "Z":
353
- zombie_cells.append(cell_rect)
354
-
355
- # Standalone beams (non-wall cells) are placed immediately
356
- if cell_has_beam and ch != "1":
357
- beam = SteelBeam(
358
- cell_rect.x, cell_rect.y, cell_rect.width, health=STEEL_BEAM_HEALTH
359
- )
360
- add_beam_to_groups(beam)
361
-
362
- game_data.areas.outer_rect = (0, 0, LEVEL_WIDTH, LEVEL_HEIGHT)
363
- game_data.areas.inner_rect = (0, 0, LEVEL_WIDTH, LEVEL_HEIGHT)
364
- game_data.areas.outside_rects = outside_rects
365
- game_data.areas.walkable_cells = walkable_cells
366
- game_data.areas.outer_wall_cells = outer_wall_cells
367
- # level_rect no longer used
368
-
369
- return {
370
- "player_cells": player_cells,
371
- "car_cells": car_cells,
372
- "zombie_cells": zombie_cells,
373
- "walkable_cells": walkable_cells,
374
- }
375
-
376
-
377
- def place_new_car(
378
- wall_group: pygame.sprite.Group,
379
- player: Player,
380
- walkable_cells: list[pygame.Rect],
381
- *,
382
- existing_cars: Sequence[Car] | None = None,
383
- appearance: str = "default",
384
- ) -> Car | None:
385
- if not walkable_cells:
386
- return None
387
-
388
- max_attempts = 150
389
- for attempt in range(max_attempts):
390
- cell = RNG.choice(walkable_cells)
391
- c_x, c_y = cell.center
392
- temp_car = Car(c_x, c_y, appearance=appearance)
393
- temp_rect = temp_car.rect.inflate(30, 30)
394
- nearby_walls = pygame.sprite.Group()
395
- nearby_walls.add(
396
- [
397
- w
398
- for w in wall_group
399
- if abs(w.rect.centerx - c_x) < 150 and abs(w.rect.centery - c_y) < 150
400
- ]
401
- )
402
- collides_wall = spritecollideany_walls(temp_car, nearby_walls)
403
- collides_player = temp_rect.colliderect(player.rect.inflate(50, 50))
404
- car_overlap = False
405
- if existing_cars:
406
- car_overlap = any(
407
- temp_car.rect.colliderect(other.rect)
408
- for other in existing_cars
409
- if other and other.alive()
410
- )
411
- if not collides_wall and not collides_player and not car_overlap:
412
- return temp_car
413
- return None
414
-
415
-
416
- def place_fuel_can(
417
- walkable_cells: list[pygame.Rect],
418
- player: Player,
419
- *,
420
- cars: Sequence[Car] | None = None,
421
- count: int = 1,
422
- ) -> FuelCan | None:
423
- """Pick a spawn spot for the fuel can away from the player (and car if given)."""
424
- if count <= 0 or not walkable_cells:
425
- return None
426
-
427
- min_player_dist = 250
428
- min_car_dist = 200
429
-
430
- for attempt in range(200):
431
- cell = RNG.choice(walkable_cells)
432
- if (
433
- math.hypot(cell.centerx - player.x, cell.centery - player.y)
434
- < min_player_dist
435
- ):
436
- continue
437
- if cars:
438
- too_close = False
439
- for parked_car in cars:
440
- if math.hypot(
441
- cell.centerx - parked_car.rect.centerx,
442
- cell.centery - parked_car.rect.centery,
443
- ) < min_car_dist:
444
- too_close = True
445
- break
446
- if too_close:
447
- continue
448
- return FuelCan(cell.centerx, cell.centery)
449
-
450
- # Fallback: drop near a random walkable cell
451
- cell = RNG.choice(walkable_cells)
452
- return FuelCan(cell.centerx, cell.centery)
453
-
454
-
455
- def place_flashlight(
456
- walkable_cells: list[pygame.Rect],
457
- player: Player,
458
- *,
459
- cars: Sequence[Car] | None = None,
460
- ) -> Flashlight | None:
461
- """Pick a spawn spot for the flashlight away from the player (and car if given)."""
462
- if not walkable_cells:
463
- return None
464
-
465
- min_player_dist = 260
466
- min_car_dist = 200
467
-
468
- for attempt in range(200):
469
- cell = RNG.choice(walkable_cells)
470
- if (
471
- math.hypot(cell.centerx - player.x, cell.centery - player.y)
472
- < min_player_dist
473
- ):
474
- continue
475
- if cars:
476
- if any(
477
- math.hypot(
478
- cell.centerx - parked.rect.centerx,
479
- cell.centery - parked.rect.centery,
480
- )
481
- < min_car_dist
482
- for parked in cars
483
- ):
484
- continue
485
- return Flashlight(cell.centerx, cell.centery)
486
-
487
- cell = RNG.choice(walkable_cells)
488
- return Flashlight(cell.centerx, cell.centery)
489
-
490
-
491
- def place_flashlights(
492
- walkable_cells: list[pygame.Rect],
493
- player: Player,
494
- *,
495
- cars: Sequence[Car] | None = None,
496
- count: int = DEFAULT_FLASHLIGHT_SPAWN_COUNT,
497
- ) -> list[Flashlight]:
498
- """Spawn multiple flashlights using the single-place helper to spread them out."""
499
- placed: list[Flashlight] = []
500
- attempts = 0
501
- max_attempts = max(200, count * 80)
502
- while len(placed) < count and attempts < max_attempts:
503
- attempts += 1
504
- fl = place_flashlight(walkable_cells, player, cars=cars)
505
- if not fl:
506
- break
507
- # Avoid clustering too tightly
508
- if any(
509
- math.hypot(
510
- other.rect.centerx - fl.rect.centerx,
511
- other.rect.centery - fl.rect.centery,
512
- )
513
- < 120
514
- for other in placed
515
- ):
516
- continue
517
- placed.append(fl)
518
- return placed
519
-
520
-
521
- def place_buddy(
522
- walkable_cells: list[pygame.Rect],
523
- player: Player,
524
- *,
525
- cars: Sequence[Car] | None = None,
526
- ) -> Survivor | None:
527
- """Spawn the stranded buddy somewhere on a walkable tile away from the player and car."""
528
- if not walkable_cells:
529
- return None
530
-
531
- min_player_dist = 240
532
- min_car_dist = 180
533
-
534
- for attempt in range(200):
535
- cell = RNG.choice(walkable_cells)
536
- if (
537
- math.hypot(cell.centerx - player.x, cell.centery - player.y)
538
- < min_player_dist
539
- ):
540
- continue
541
- if cars:
542
- if any(
543
- math.hypot(
544
- cell.centerx - parked.rect.centerx,
545
- cell.centery - parked.rect.centery,
546
- )
547
- < min_car_dist
548
- for parked in cars
549
- ):
550
- continue
551
- return Survivor(cell.centerx, cell.centery, is_buddy=True)
552
-
553
- cell = RNG.choice(walkable_cells)
554
- return Survivor(cell.centerx, cell.centery, is_buddy=True)
555
-
556
-
557
- def place_buddies(
558
- walkable_cells: list[pygame.Rect],
559
- player: Player,
560
- *,
561
- cars: Sequence[Car] | None = None,
562
- count: int = 1,
563
- ) -> list[Survivor]:
564
- placed: list[Survivor] = []
565
- if count <= 0:
566
- return placed
567
- attempts = 0
568
- max_attempts = max(200, count * 60)
569
- while len(placed) < count and attempts < max_attempts:
570
- attempts += 1
571
- buddy = place_buddy(walkable_cells, player, cars=cars)
572
- if not buddy:
573
- break
574
- if any(
575
- math.hypot(
576
- other.rect.centerx - buddy.rect.centerx,
577
- other.rect.centery - buddy.rect.centery,
578
- )
579
- < 100
580
- for other in placed
581
- ):
582
- continue
583
- placed.append(buddy)
584
- return placed
585
-
586
-
587
- def scatter_positions_on_walkable(
588
- walkable_cells: list[pygame.Rect],
589
- spawn_rate: float,
590
- *,
591
- jitter_ratio: float = 0.35,
592
- ) -> list[tuple[int, int]]:
593
- positions: list[tuple[int, int]] = []
594
- if not walkable_cells or spawn_rate <= 0:
595
- return positions
596
-
597
- clamped_rate = max(0.0, min(1.0, spawn_rate))
598
- for cell in walkable_cells:
599
- if RNG.random() >= clamped_rate:
600
- continue
601
- jitter_x = RNG.uniform(-cell.width * jitter_ratio, cell.width * jitter_ratio)
602
- jitter_y = RNG.uniform(
603
- -cell.height * jitter_ratio, cell.height * jitter_ratio
604
- )
605
- positions.append((int(cell.centerx + jitter_x), int(cell.centery + jitter_y)))
606
- return positions
607
-
608
-
609
- def spawn_survivors(
610
- game_data: GameData, layout_data: Mapping[str, list[pygame.Rect]]
611
- ) -> list[Survivor]:
612
- """Populate rescue-stage survivors and buddy-stage buddies."""
613
- survivors: list[Survivor] = []
614
- if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
615
- return survivors
616
-
617
- walkable = layout_data.get("walkable_cells", [])
618
- wall_group = game_data.groups.wall_group
619
- survivor_group = game_data.groups.survivor_group
620
- all_sprites = game_data.groups.all_sprites
621
-
622
- if game_data.stage.rescue_stage:
623
- for pos in scatter_positions_on_walkable(
624
- walkable, game_data.stage.survivor_spawn_rate
625
- ):
626
- s = Survivor(*pos)
627
- if spritecollideany_walls(s, wall_group):
628
- continue
629
- survivor_group.add(s)
630
- all_sprites.add(s, layer=1)
631
- survivors.append(s)
632
-
633
- if game_data.stage.buddy_required_count > 0:
634
- buddy_count = max(0, game_data.stage.buddy_required_count)
635
- buddies: list[Survivor] = []
636
- if game_data.player:
637
- buddies = place_buddies(
638
- walkable,
639
- game_data.player,
640
- cars=game_data.waiting_cars,
641
- count=buddy_count,
642
- )
643
- for buddy in buddies:
644
- if spritecollideany_walls(buddy, wall_group):
645
- continue
646
- survivor_group.add(buddy)
647
- all_sprites.add(buddy, layer=2)
648
- survivors.append(buddy)
649
-
650
- return survivors
651
-
652
-
653
- def update_survivors(
654
- game_data: GameData, wall_index: WallIndex | None = None
655
- ) -> None:
656
- if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
657
- return
658
- survivor_group = game_data.groups.survivor_group
659
- wall_group = game_data.groups.wall_group
660
- player = game_data.player
661
- car = game_data.car
662
- if not player:
663
- return
664
- target_rect = car.rect if player.in_car and car and car.alive() else player.rect
665
- target_pos = target_rect.center
666
- survivors = [s for s in survivor_group if s.alive()]
667
- for survivor in survivors:
668
- survivor.update_behavior(target_pos, wall_group, wall_index=wall_index)
669
-
670
- # Gently prevent survivors from overlapping the player or each other
671
- def _separate_from_point(
672
- survivor: Survivor, point: tuple[float, float], min_dist: float
673
- ) -> None:
674
- dx = point[0] - survivor.x
675
- dy = point[1] - survivor.y
676
- dist = math.hypot(dx, dy)
677
- if dist == 0:
678
- angle = RNG.uniform(0, math.tau)
679
- dx, dy = math.cos(angle), math.sin(angle)
680
- dist = 1
681
- if dist < min_dist:
682
- push = min_dist - dist
683
- survivor.x -= (dx / dist) * push
684
- survivor.y -= (dy / dist) * push
685
- survivor.rect.center = (int(survivor.x), int(survivor.y))
686
-
687
- player_overlap = (SURVIVOR_RADIUS + PLAYER_RADIUS) * 1.05
688
- survivor_overlap = (SURVIVOR_RADIUS * 2) * 1.05
689
-
690
- player_point = (player.x, player.y)
691
- for survivor in survivors:
692
- _separate_from_point(survivor, player_point, player_overlap)
693
-
694
- survivors_with_x = sorted(
695
- ((survivor.x, survivor) for survivor in survivors), key=lambda item: item[0]
696
- )
697
- for i, (base_x, survivor) in enumerate(survivors_with_x):
698
- for other_base_x, other in survivors_with_x[i + 1 :]:
699
- if other_base_x - base_x > survivor_overlap:
700
- break
701
- dx = other.x - survivor.x
702
- dy = other.y - survivor.y
703
- dist = math.hypot(dx, dy)
704
- if dist == 0:
705
- angle = RNG.uniform(0, math.tau)
706
- dx, dy = math.cos(angle), math.sin(angle)
707
- dist = 1
708
- if dist < survivor_overlap:
709
- push = (survivor_overlap - dist) / 2
710
- offset_x = (dx / dist) * push
711
- offset_y = (dy / dist) * push
712
- survivor.x -= offset_x
713
- survivor.y -= offset_y
714
- other.x += offset_x
715
- other.y += offset_y
716
- survivor.rect.center = (int(survivor.x), int(survivor.y))
717
- other.rect.center = (int(other.x), int(other.y))
718
-
719
-
720
- def calculate_car_speed_for_passengers(
721
- passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
722
- ) -> float:
723
- cap = max(1, capacity)
724
- load_ratio = max(0.0, passengers / cap)
725
- penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * load_ratio
726
- penalty = min(0.95, max(0.0, penalty))
727
- adjusted = CAR_SPEED * (1 - penalty)
728
- if passengers <= cap:
729
- return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, adjusted)
730
-
731
- overload = passengers - cap
732
- overload_factor = 1 / math.sqrt(overload + 1)
733
- overloaded_speed = CAR_SPEED * overload_factor
734
- return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, overloaded_speed)
735
-
736
-
737
- def apply_passenger_speed_penalty(game_data: GameData) -> None:
738
- car = game_data.car
739
- if not car:
740
- return
741
- if not game_data.stage.rescue_stage:
742
- car.speed = CAR_SPEED
743
- return
744
- car.speed = calculate_car_speed_for_passengers(
745
- game_data.state.survivors_onboard,
746
- capacity=game_data.state.survivor_capacity,
747
- )
748
-
749
-
750
- def increase_survivor_capacity(game_data: GameData, increments: int = 1) -> None:
751
- if increments <= 0:
752
- return
753
- if not game_data.stage.rescue_stage:
754
- return
755
- state = game_data.state
756
- state.survivor_capacity += increments * SURVIVOR_MAX_SAFE_PASSENGERS
757
- apply_passenger_speed_penalty(game_data)
758
-
759
-
760
- def rect_visible_on_screen(camera: Camera | None, rect: pygame.Rect) -> bool:
761
- if camera is None:
762
- return False
763
- return camera.apply_rect(rect).colliderect(LOGICAL_SCREEN_RECT)
764
-
765
-
766
- def waiting_car_target_count(stage: Stage) -> int:
767
- return SURVIVOR_STAGE_WAITING_CAR_COUNT if stage.rescue_stage else 1
768
-
769
-
770
- def spawn_waiting_car(game_data: GameData) -> Car | None:
771
- """Attempt to place an additional parked car on the map."""
772
- player = game_data.player
773
- if not player:
774
- return None
775
- walkable_cells = game_data.areas.walkable_cells
776
- if not walkable_cells:
777
- return None
778
- wall_group = game_data.groups.wall_group
779
- all_sprites = game_data.groups.all_sprites
780
- active_car = game_data.car if game_data.car and game_data.car.alive() else None
781
- waiting = alive_waiting_cars(game_data)
782
- obstacles: list[Car] = list(waiting)
783
- if active_car:
784
- obstacles.append(active_car)
785
- camera = game_data.camera
786
- appearance = car_appearance_for_stage(game_data.stage)
787
- offscreen_attempts = 6
788
- while offscreen_attempts > 0:
789
- new_car = place_new_car(
790
- wall_group,
791
- player,
792
- walkable_cells,
793
- existing_cars=obstacles,
794
- appearance=appearance,
795
- )
796
- if not new_car:
797
- return None
798
- if rect_visible_on_screen(camera, new_car.rect):
799
- offscreen_attempts -= 1
800
- continue
801
- game_data.waiting_cars.append(new_car)
802
- all_sprites.add(new_car, layer=1)
803
- return new_car
804
- return None
805
-
806
-
807
- def maintain_waiting_car_supply(
808
- game_data: GameData, *, minimum: int | None = None
809
- ) -> None:
810
- """Ensure a baseline count of parked cars exists."""
811
- target = 1 if minimum is None else max(0, minimum)
812
- current = len(alive_waiting_cars(game_data))
813
- while current < target:
814
- new_car = spawn_waiting_car(game_data)
815
- if not new_car:
816
- break
817
- current += 1
818
-
819
-
820
- def alive_waiting_cars(game_data: GameData) -> list[Car]:
821
- """Return the list of parked cars that still exist, pruning any destroyed sprites."""
822
- cars = [car for car in game_data.waiting_cars if car.alive()]
823
- game_data.waiting_cars = cars
824
- log_waiting_car_count(game_data)
825
- return cars
826
-
827
-
828
- def log_waiting_car_count(game_data: GameData, *, force: bool = False) -> None:
829
- """Print the number of waiting cars when it changes."""
830
- current = len(game_data.waiting_cars)
831
- if not force and current == game_data.last_logged_waiting_cars:
832
- return
833
- game_data.last_logged_waiting_cars = current
834
-
835
-
836
- def nearest_waiting_car(
837
- game_data: GameData, origin: tuple[float, float]
838
- ) -> Car | None:
839
- """Find the closest waiting car to an origin point."""
840
- cars = alive_waiting_cars(game_data)
841
- if not cars:
842
- return None
843
- return min(
844
- cars,
845
- key=lambda car: math.hypot(car.rect.centerx - origin[0], car.rect.centery - origin[1]),
846
- )
847
-
848
-
849
- def add_survivor_message(game_data: GameData, text: str) -> None:
850
- expires = pygame.time.get_ticks() + SURVIVOR_MESSAGE_DURATION_MS
851
- game_data.state.survivor_messages.append({"text": text, "expires_at": expires})
852
-
853
-
854
- def random_survivor_conversion_line() -> str:
855
- if not SURVIVOR_CONVERSION_LINE_KEYS:
856
- return ""
857
- key = RNG.choice(SURVIVOR_CONVERSION_LINE_KEYS)
858
- return tr(key)
859
-
860
-
861
- def cleanup_survivor_messages(state: ProgressState) -> None:
862
- now = pygame.time.get_ticks()
863
- state.survivor_messages = [
864
- msg for msg in state.survivor_messages if msg.get("expires_at", 0) > now
865
- ]
866
-
867
-
868
- def drop_survivors_from_car(game_data: GameData, origin: tuple[int, int]) -> None:
869
- """Respawn boarded survivors back into the world after a crash."""
870
- count = game_data.state.survivors_onboard
871
- if count <= 0:
872
- return
873
- wall_group = game_data.groups.wall_group
874
- survivor_group = game_data.groups.survivor_group
875
- all_sprites = game_data.groups.all_sprites
876
-
877
- for survivor_idx in range(count):
878
- placed = False
879
- for attempt in range(6):
880
- angle = RNG.uniform(0, math.tau)
881
- dist = RNG.uniform(16, 40)
882
- pos = (
883
- origin[0] + math.cos(angle) * dist,
884
- origin[1] + math.sin(angle) * dist,
885
- )
886
- s = Survivor(*pos)
887
- if not spritecollideany_walls(s, wall_group):
888
- survivor_group.add(s)
889
- all_sprites.add(s, layer=1)
890
- placed = True
891
- break
892
- if not placed:
893
- s = Survivor(*origin)
894
- survivor_group.add(s)
895
- all_sprites.add(s, layer=1)
896
-
897
- game_data.state.survivors_onboard = 0
898
- apply_passenger_speed_penalty(game_data)
899
-
900
-
901
- def handle_survivor_zombie_collisions(
902
- game_data: GameData, config: dict[str, Any]
903
- ) -> None:
904
- if not game_data.stage.rescue_stage:
905
- return
906
- survivor_group = game_data.groups.survivor_group
907
- if not survivor_group:
908
- return
909
- zombie_group = game_data.groups.zombie_group
910
- zombies = [z for z in zombie_group if z.alive()]
911
- if not zombies:
912
- return
913
- zombies.sort(key=lambda s: s.rect.centerx)
914
- zombie_xs = [z.rect.centerx for z in zombies]
915
- camera = game_data.camera
916
- walkable_cells = game_data.areas.walkable_cells
917
-
918
- for survivor in list(survivor_group):
919
- if not survivor.alive():
920
- continue
921
- survivor_radius = survivor.radius
922
- search_radius = survivor_radius + ZOMBIE_RADIUS
923
- search_radius_sq = search_radius * search_radius
924
-
925
- min_x = survivor.rect.centerx - search_radius
926
- max_x = survivor.rect.centerx + search_radius
927
- start_idx = bisect_left(zombie_xs, min_x)
928
- collided = False
929
- for idx in range(start_idx, len(zombies)):
930
- zombie_x = zombie_xs[idx]
931
- if zombie_x > max_x:
932
- break
933
- zombie = zombies[idx]
934
- if not zombie.alive():
935
- continue
936
- dy = zombie.rect.centery - survivor.rect.centery
937
- if abs(dy) > search_radius:
938
- continue
939
- dx = zombie_x - survivor.rect.centerx
940
- if dx * dx + dy * dy <= search_radius_sq:
941
- collided = True
942
- break
943
-
944
- if not collided:
945
- continue
946
- if not rect_visible_on_screen(camera, survivor.rect):
947
- if walkable_cells:
948
- new_cell = RNG.choice(walkable_cells)
949
- survivor.teleport(new_cell.center)
950
- else:
951
- survivor.teleport((LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2))
952
- continue
953
- survivor.kill()
954
- line = random_survivor_conversion_line()
955
- if line:
956
- add_survivor_message(game_data, line)
957
- new_zombie = create_zombie(
958
- config,
959
- start_pos=survivor.rect.center,
960
- stage=game_data.stage,
961
- outer_wall_cells=game_data.areas.outer_wall_cells,
962
- )
963
- zombie_group.add(new_zombie)
964
- game_data.groups.all_sprites.add(new_zombie, layer=1)
965
- insert_idx = bisect_left(zombie_xs, new_zombie.rect.centerx)
966
- zombie_xs.insert(insert_idx, new_zombie.rect.centerx)
967
- zombies.insert(insert_idx, new_zombie)
968
-
969
-
970
- def respawn_buddies_near_player(game_data: GameData) -> None:
971
- """Bring back onboard buddies near the player after losing the car."""
972
- if game_data.stage.buddy_required_count <= 0:
973
- return
974
- count = game_data.state.buddy_onboard
975
- if count <= 0:
976
- return
977
-
978
- player = game_data.player
979
- assert player is not None
980
- wall_group = game_data.groups.wall_group
981
- offsets = [
982
- (BUDDY_RADIUS * 3, 0),
983
- (-BUDDY_RADIUS * 3, 0),
984
- (0, BUDDY_RADIUS * 3),
985
- (0, -BUDDY_RADIUS * 3),
986
- (0, 0),
987
- ]
988
- for _ in range(count):
989
- spawn_pos = (int(player.x), int(player.y))
990
- for dx, dy in offsets:
991
- candidate = Survivor(player.x + dx, player.y + dy, is_buddy=True)
992
- if not spritecollideany_walls(candidate, wall_group):
993
- spawn_pos = (candidate.x, candidate.y)
994
- break
995
-
996
- buddy = Survivor(*spawn_pos, is_buddy=True)
997
- buddy.following = True
998
- game_data.groups.all_sprites.add(buddy, layer=2)
999
- game_data.groups.survivor_group.add(buddy)
1000
- game_data.state.buddy_onboard = 0
1001
-
1002
-
1003
- def get_shrunk_sprite(
1004
- sprite_obj: pygame.sprite.Sprite, scale_x: float, *, scale_y: float | None = None
1005
- ) -> pygame.sprite.Sprite:
1006
- if scale_y is None:
1007
- scale_y = scale_x
1008
-
1009
- original_rect = sprite_obj.rect
1010
- shrunk_width = int(original_rect.width * scale_x)
1011
- shrunk_height = int(original_rect.height * scale_y)
1012
-
1013
- shrunk_width = max(1, shrunk_width)
1014
- shrunk_height = max(1, shrunk_height)
1015
-
1016
- rect = pygame.Rect(0, 0, shrunk_width, shrunk_height)
1017
- rect.center = original_rect.center
1018
-
1019
- new_sprite = pygame.sprite.Sprite()
1020
- new_sprite.rect = rect
1021
-
1022
- return new_sprite
1023
-
1024
-
1025
- def update_footprints(game_data: GameData, config: dict[str, Any]) -> None:
1026
- """Record player steps and clean up old footprints."""
1027
- state = game_data.state
1028
- player = game_data.player
1029
- assert player is not None
1030
- footprints_enabled = config.get("footprints", {}).get("enabled", True)
1031
- if not footprints_enabled:
1032
- state.footprints = []
1033
- state.last_footprint_pos = None
1034
- return
1035
-
1036
- now = pygame.time.get_ticks()
1037
-
1038
- footprints = state.footprints
1039
- if not player.in_car:
1040
- last_pos = state.last_footprint_pos
1041
- dist = (
1042
- math.hypot(player.x - last_pos[0], player.y - last_pos[1])
1043
- if last_pos
1044
- else None
1045
- )
1046
- if last_pos is None or (dist is not None and dist >= FOOTPRINT_STEP_DISTANCE):
1047
- footprints.append({"pos": (player.x, player.y), "time": now})
1048
- state.last_footprint_pos = (player.x, player.y)
1049
-
1050
- if len(footprints) > FOOTPRINT_MAX:
1051
- footprints = footprints[-FOOTPRINT_MAX:]
1052
-
1053
- state.footprints = footprints
1054
-
1055
-
1056
- def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
1057
- """Initialize and return the base game state objects."""
1058
- starts_with_fuel = not stage.requires_fuel
1059
- if stage.survival_stage:
1060
- starts_with_fuel = False
1061
- starts_with_flashlight = False
1062
- initial_flashlights = 1 if starts_with_flashlight else 0
1063
- initial_palette_key = ambient_palette_key_for_flashlights(initial_flashlights)
1064
- game_state = ProgressState(
1065
- game_over=False,
1066
- game_won=False,
1067
- game_over_message=None,
1068
- game_over_at=None,
1069
- scaled_overview=None,
1070
- overview_created=False,
1071
- footprints=[],
1072
- last_footprint_pos=None,
1073
- elapsed_play_ms=0,
1074
- has_fuel=starts_with_fuel,
1075
- flashlight_count=initial_flashlights,
1076
- ambient_palette_key=initial_palette_key,
1077
- hint_expires_at=0,
1078
- hint_target_type=None,
1079
- fuel_message_until=0,
1080
- buddy_rescued=0,
1081
- buddy_onboard=0,
1082
- survivors_onboard=0,
1083
- survivors_rescued=0,
1084
- survivor_messages=[],
1085
- survivor_capacity=SURVIVOR_MAX_SAFE_PASSENGERS,
1086
- seed=None,
1087
- survival_elapsed_ms=0,
1088
- survival_goal_ms=max(0, stage.survival_goal_ms),
1089
- dawn_ready=False,
1090
- dawn_prompt_at=None,
1091
- time_accel_active=False,
1092
- last_zombie_spawn_time=0,
1093
- dawn_carbonized=False,
1094
- debug_mode=False,
1095
- )
1096
-
1097
- # Create sprite groups
1098
- all_sprites = pygame.sprite.LayeredUpdates()
1099
- wall_group = pygame.sprite.Group()
1100
- zombie_group = pygame.sprite.Group()
1101
- survivor_group = pygame.sprite.Group()
1102
-
1103
- # Create camera
1104
- camera = Camera(LEVEL_WIDTH, LEVEL_HEIGHT)
1105
-
1106
- # Define level areas (will be filled by blueprint generation)
1107
- outer_rect = 0, 0, LEVEL_WIDTH, LEVEL_HEIGHT
1108
- inner_rect = outer_rect
1109
-
1110
- return GameData(
1111
- state=game_state,
1112
- groups=Groups(
1113
- all_sprites=all_sprites,
1114
- wall_group=wall_group,
1115
- zombie_group=zombie_group,
1116
- survivor_group=survivor_group,
1117
- ),
1118
- camera=camera,
1119
- areas=Areas(
1120
- outer_rect=outer_rect,
1121
- inner_rect=inner_rect,
1122
- outside_rects=[],
1123
- walkable_cells=[],
1124
- outer_wall_cells=set(),
1125
- ),
1126
- fog={
1127
- "hatch_patterns": {},
1128
- "overlays": {},
1129
- },
1130
- stage=stage,
1131
- fuel=None,
1132
- flashlights=[],
1133
- )
1134
-
1135
-
1136
- def setup_player_and_cars(
1137
- game_data: GameData,
1138
- layout_data: Mapping[str, list[pygame.Rect]],
1139
- *,
1140
- car_count: int = 1,
1141
- ) -> tuple[Player, list[Car]]:
1142
- """Create the player plus one or more parked cars using blueprint candidates."""
1143
- all_sprites = game_data.groups.all_sprites
1144
- walkable_cells: list[pygame.Rect] = layout_data["walkable_cells"]
1145
-
1146
- def pick_center(cells: list[pygame.Rect]) -> tuple[int, int]:
1147
- return (
1148
- RNG.choice(cells).center
1149
- if cells
1150
- else (LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2)
1151
- )
1152
-
1153
- player_pos = pick_center(layout_data["player_cells"] or walkable_cells)
1154
- player = Player(*player_pos)
1155
-
1156
- car_candidates = list(layout_data["car_cells"] or walkable_cells)
1157
- waiting_cars: list[Car] = []
1158
- car_appearance = car_appearance_for_stage(game_data.stage)
1159
-
1160
- def _pick_car_position() -> tuple[int, int]:
1161
- """Favor distant cells for the first car, otherwise fall back to random picks."""
1162
- if not car_candidates:
1163
- return (player_pos[0] + 200, player_pos[1])
1164
- RNG.shuffle(car_candidates)
1165
- for candidate in car_candidates:
1166
- if (
1167
- math.hypot(
1168
- candidate.centerx - player_pos[0],
1169
- candidate.centery - player_pos[1],
1170
- )
1171
- >= 400
1172
- ):
1173
- car_candidates.remove(candidate)
1174
- return candidate.center
1175
- # No far-enough cells found; pick the first available
1176
- choice = car_candidates.pop()
1177
- return choice.center
1178
-
1179
- for idx in range(max(1, car_count)):
1180
- car_pos = _pick_car_position()
1181
- car = Car(*car_pos, appearance=car_appearance)
1182
- waiting_cars.append(car)
1183
- all_sprites.add(car, layer=1)
1184
- if not car_candidates:
1185
- break
1186
-
1187
- all_sprites.add(player, layer=2)
1188
- return player, waiting_cars
1189
-
1190
-
1191
- def spawn_initial_zombies(
1192
- game_data: GameData,
1193
- player: Player,
1194
- layout_data: Mapping[str, list[pygame.Rect]],
1195
- config: dict[str, Any],
1196
- ) -> None:
1197
- """Spawn initial zombies using blueprint candidate cells."""
1198
- wall_group = game_data.groups.wall_group
1199
- zombie_group = game_data.groups.zombie_group
1200
- all_sprites = game_data.groups.all_sprites
1201
-
1202
- spawn_cells = layout_data["walkable_cells"]
1203
- if not spawn_cells:
1204
- return
1205
-
1206
- spawn_rate = max(0.0, getattr(game_data.stage, "initial_interior_spawn_rate", 0.0))
1207
- positions = scatter_positions_on_walkable(spawn_cells, spawn_rate)
1208
- if not positions:
1209
- positions = scatter_positions_on_walkable(spawn_cells, spawn_rate * 1.5)
1210
-
1211
- for pos in positions:
1212
- if (
1213
- math.hypot(pos[0] - player.x, pos[1] - player.y)
1214
- < ZOMBIE_SPAWN_PLAYER_BUFFER
1215
- ):
1216
- continue
1217
- tentative = create_zombie(
1218
- config,
1219
- start_pos=pos,
1220
- stage=game_data.stage,
1221
- outer_wall_cells=game_data.areas.outer_wall_cells,
1222
- )
1223
- if spritecollideany_walls(tentative, wall_group):
1224
- continue
1225
- zombie_group.add(tentative)
1226
- all_sprites.add(tentative, layer=1)
1227
-
1228
- interval = max(1, getattr(game_data.stage, "spawn_interval_ms", ZOMBIE_SPAWN_DELAY_MS))
1229
- game_data.state.last_zombie_spawn_time = pygame.time.get_ticks() - interval
1230
-
1231
-
1232
- def spawn_nearby_zombie(
1233
- game_data: GameData,
1234
- config: dict[str, Any],
1235
- ) -> Zombie | None:
1236
- """Spawn a zombie just outside of the current camera frustum."""
1237
- player = game_data.player
1238
- if not player:
1239
- return None
1240
- zombie_group = game_data.groups.zombie_group
1241
- if len(zombie_group) >= MAX_ZOMBIES:
1242
- return None
1243
- camera = game_data.camera
1244
- view_rect = pygame.Rect(
1245
- -camera.camera.x,
1246
- -camera.camera.y,
1247
- SCREEN_WIDTH,
1248
- SCREEN_HEIGHT,
1249
- )
1250
- view_rect.inflate_ip(
1251
- SURVIVAL_NEAR_SPAWN_CAMERA_MARGIN * 2,
1252
- SURVIVAL_NEAR_SPAWN_CAMERA_MARGIN * 2,
1253
- )
1254
- wall_group = game_data.groups.wall_group
1255
- all_sprites = game_data.groups.all_sprites
1256
- for _ in range(18):
1257
- angle = RNG.uniform(0, math.tau)
1258
- distance = RNG.uniform(
1259
- SURVIVAL_NEAR_SPAWN_MIN_DISTANCE,
1260
- SURVIVAL_NEAR_SPAWN_MAX_DISTANCE,
1261
- )
1262
- spawn_x = player.x + math.cos(angle) * distance
1263
- spawn_y = player.y + math.sin(angle) * distance
1264
- candidate = (
1265
- int(max(0, min(LEVEL_WIDTH, spawn_x))),
1266
- int(max(0, min(LEVEL_HEIGHT, spawn_y))),
1267
- )
1268
- if view_rect.collidepoint(candidate):
1269
- continue
1270
- new_zombie = create_zombie(
1271
- config,
1272
- start_pos=candidate,
1273
- stage=game_data.stage,
1274
- outer_wall_cells=game_data.areas.outer_wall_cells,
1275
- )
1276
- if spritecollideany_walls(new_zombie, wall_group):
1277
- continue
1278
- zombie_group.add(new_zombie)
1279
- all_sprites.add(new_zombie, layer=1)
1280
- return new_zombie
1281
- return None
1282
-
1283
-
1284
- def spawn_exterior_zombie(
1285
- game_data: GameData,
1286
- config: dict[str, Any],
1287
- ) -> Zombie | None:
1288
- """Spawn a zombie using the standard exterior hint logic."""
1289
- player = game_data.player
1290
- if not player:
1291
- return None
1292
- zombie_group = game_data.groups.zombie_group
1293
- all_sprites = game_data.groups.all_sprites
1294
- new_zombie = create_zombie(
1295
- config,
1296
- hint_pos=(player.x, player.y),
1297
- stage=game_data.stage,
1298
- outer_wall_cells=game_data.areas.outer_wall_cells,
1299
- )
1300
- zombie_group.add(new_zombie)
1301
- all_sprites.add(new_zombie, layer=1)
1302
- return new_zombie
1303
-
1304
-
1305
- def spawn_weighted_zombie(
1306
- game_data: GameData,
1307
- config: dict[str, Any],
1308
- ) -> bool:
1309
- """Spawn a zombie according to the stage's interior/exterior mix."""
1310
- stage = game_data.stage
1311
- def _spawn(choice: str) -> bool:
1312
- if choice == "interior":
1313
- return spawn_nearby_zombie(game_data, config) is not None
1314
- return spawn_exterior_zombie(game_data, config) is not None
1315
-
1316
- interior_weight = max(0.0, stage.interior_spawn_weight)
1317
- exterior_weight = max(0.0, stage.exterior_spawn_weight)
1318
- total_weight = interior_weight + exterior_weight
1319
- if total_weight <= 0:
1320
- # Fall back to exterior spawns if weights are unset or invalid.
1321
- return _spawn("exterior")
1322
-
1323
- pick = RNG.uniform(0, total_weight)
1324
- if pick <= interior_weight:
1325
- if _spawn("interior"):
1326
- return True
1327
- return _spawn("exterior")
1328
- if _spawn("exterior"):
1329
- return True
1330
- return _spawn("interior")
1331
-
1332
-
1333
- def carbonize_outdoor_zombies(game_data: GameData) -> None:
1334
- """Petrify zombies that have already broken through to the exterior."""
1335
- outside_rects = game_data.areas.outside_rects or []
1336
- if not outside_rects:
1337
- return
1338
- group = game_data.groups.zombie_group
1339
- if not group:
1340
- return
1341
- for zombie in list(group):
1342
- alive = getattr(zombie, "alive", lambda: False)
1343
- if not alive():
1344
- continue
1345
- center = zombie.rect.center
1346
- if any(rect_obj.collidepoint(center) for rect_obj in outside_rects):
1347
- carbonize = getattr(zombie, "carbonize", None)
1348
- if carbonize:
1349
- carbonize()
1350
-
1351
-
1352
- def update_survival_timer(game_data: GameData, dt_ms: int) -> None:
1353
- """Advance the survival countdown and trigger dawn handoff."""
1354
- stage = game_data.stage
1355
- state = game_data.state
1356
- if not stage.survival_stage:
1357
- return
1358
- if state.survival_goal_ms <= 0 or dt_ms <= 0:
1359
- return
1360
- state.survival_elapsed_ms = min(
1361
- state.survival_goal_ms,
1362
- state.survival_elapsed_ms + dt_ms,
1363
- )
1364
- if not state.dawn_ready and state.survival_elapsed_ms >= state.survival_goal_ms:
1365
- state.dawn_ready = True
1366
- state.dawn_prompt_at = pygame.time.get_ticks()
1367
- set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=True)
1368
- if state.dawn_ready:
1369
- carbonize_outdoor_zombies(game_data)
1370
- state.dawn_carbonized = True
1371
-
1372
-
1373
- def process_player_input(
1374
- keys: Sequence[bool], player: Player, car: Car | None
1375
- ) -> tuple[float, float, float, float]:
1376
- """Process keyboard input and return movement deltas."""
1377
- dx_input, dy_input = 0, 0
1378
- if keys[pygame.K_w] or keys[pygame.K_UP]:
1379
- dy_input -= 1
1380
- if keys[pygame.K_s] or keys[pygame.K_DOWN]:
1381
- dy_input += 1
1382
- if keys[pygame.K_a] or keys[pygame.K_LEFT]:
1383
- dx_input -= 1
1384
- if keys[pygame.K_d] or keys[pygame.K_RIGHT]:
1385
- dx_input += 1
1386
-
1387
- player_dx, player_dy, car_dx, car_dy = 0, 0, 0, 0
1388
-
1389
- if player.in_car and car and car.alive():
1390
- target_speed = getattr(car, "speed", CAR_SPEED)
1391
- move_len = math.hypot(dx_input, dy_input)
1392
- if move_len > 0:
1393
- car_dx, car_dy = (
1394
- (dx_input / move_len) * target_speed,
1395
- (dy_input / move_len) * target_speed,
1396
- )
1397
- elif not player.in_car:
1398
- target_speed = PLAYER_SPEED
1399
- move_len = math.hypot(dx_input, dy_input)
1400
- if move_len > 0:
1401
- player_dx, player_dy = (
1402
- (dx_input / move_len) * target_speed,
1403
- (dy_input / move_len) * target_speed,
1404
- )
1405
-
1406
- return player_dx, player_dy, car_dx, car_dy
1407
-
1408
-
1409
- def update_entities(
1410
- game_data: GameData,
1411
- player_dx: float,
1412
- player_dy: float,
1413
- car_dx: float,
1414
- car_dy: float,
1415
- config: dict[str, Any],
1416
- wall_index: WallIndex | None = None,
1417
- ) -> None:
1418
- """Update positions and states of game entities."""
1419
- player = game_data.player
1420
- assert player is not None
1421
- car = game_data.car
1422
- wall_group = game_data.groups.wall_group
1423
- all_sprites = game_data.groups.all_sprites
1424
- zombie_group = game_data.groups.zombie_group
1425
- survivor_group = game_data.groups.survivor_group
1426
- camera = game_data.camera
1427
- stage = game_data.stage
1428
- active_car = car if car and car.alive() else None
1429
-
1430
- all_walls = list(wall_group) if wall_index is None else None
1431
-
1432
- def walls_near(center: tuple[float, float], radius: float) -> list[Wall]:
1433
- if wall_index is None:
1434
- return all_walls or []
1435
- return walls_for_radius(wall_index, center, radius)
1436
-
1437
- # Update player/car movement
1438
- if player.in_car and active_car:
1439
- car_walls = walls_near((active_car.x, active_car.y), 150.0)
1440
- active_car.move(car_dx, car_dy, car_walls)
1441
- player.rect.center = active_car.rect.center
1442
- player.x, player.y = active_car.x, active_car.y
1443
- elif not player.in_car:
1444
- # Ensure player is in all_sprites if not in car
1445
- if player not in all_sprites:
1446
- all_sprites.add(player, layer=2)
1447
- player.move(player_dx, player_dy, wall_group, wall_index=wall_index)
1448
- else:
1449
- # Player flagged as in-car but car is gone; drop them back to foot control
1450
- player.in_car = False
1451
-
1452
- # Update camera
1453
- target_for_camera = active_car if player.in_car and active_car else player
1454
- camera.update(target_for_camera)
1455
-
1456
- update_survivors(game_data, wall_index=wall_index)
1457
-
1458
- # Spawn new zombies if needed
1459
- current_time = pygame.time.get_ticks()
1460
- spawn_interval = max(1, getattr(stage, "spawn_interval_ms", ZOMBIE_SPAWN_DELAY_MS))
1461
- spawn_blocked = stage.survival_stage and game_data.state.dawn_ready
1462
- if (
1463
- len(zombie_group) < MAX_ZOMBIES
1464
- and not spawn_blocked
1465
- and current_time - game_data.state.last_zombie_spawn_time > spawn_interval
1466
- ):
1467
- if spawn_weighted_zombie(game_data, config):
1468
- game_data.state.last_zombie_spawn_time = current_time
1469
-
1470
- # Update zombies
1471
- target_center = (
1472
- active_car.rect.center if player.in_car and active_car else player.rect.center
1473
- )
1474
- buddies = [
1475
- survivor
1476
- for survivor in survivor_group
1477
- if survivor.alive() and survivor.is_buddy and not survivor.rescued
1478
- ]
1479
- buddies_on_screen = [
1480
- buddy for buddy in buddies if rect_visible_on_screen(camera, buddy.rect)
1481
- ]
1482
-
1483
- survivors_on_screen: list[Survivor] = []
1484
- if stage.rescue_stage:
1485
- for survivor in survivor_group:
1486
- if survivor.alive():
1487
- if rect_visible_on_screen(camera, survivor.rect):
1488
- survivors_on_screen.append(survivor)
1489
-
1490
- zombies_sorted: list[Zombie] = sorted(list(zombie_group), key=lambda z: z.x)
1491
-
1492
- def _nearby_zombies(index: int) -> list[Zombie]:
1493
- center = zombies_sorted[index]
1494
- neighbors: list[Zombie] = []
1495
- search_radius = ZOMBIE_SEPARATION_DISTANCE + PLAYER_SPEED
1496
- for left in range(index - 1, -1, -1):
1497
- other = zombies_sorted[left]
1498
- if center.x - other.x > search_radius:
1499
- break
1500
- if other.alive():
1501
- neighbors.append(other)
1502
- for right in range(index + 1, len(zombies_sorted)):
1503
- other = zombies_sorted[right]
1504
- if other.x - center.x > search_radius:
1505
- break
1506
- if other.alive():
1507
- neighbors.append(other)
1508
- return neighbors
1509
-
1510
- for idx, zombie in enumerate(zombies_sorted):
1511
- target = target_center
1512
- if buddies_on_screen:
1513
- dist_to_target = math.hypot(
1514
- target_center[0] - zombie.x, target_center[1] - zombie.y
1515
- )
1516
- nearest_buddy = min(
1517
- buddies_on_screen,
1518
- key=lambda buddy: math.hypot(
1519
- buddy.rect.centerx - zombie.x, buddy.rect.centery - zombie.y
1520
- ),
1521
- )
1522
- dist_to_buddy = math.hypot(
1523
- nearest_buddy.rect.centerx - zombie.x,
1524
- nearest_buddy.rect.centery - zombie.y,
1525
- )
1526
- if dist_to_buddy < dist_to_target:
1527
- target = nearest_buddy.rect.center
1528
-
1529
- if stage.rescue_stage:
1530
- zombie_on_screen = rect_visible_on_screen(camera, zombie.rect)
1531
- if zombie_on_screen:
1532
- candidate_positions: list[tuple[int, int]] = []
1533
- for survivor in survivors_on_screen:
1534
- candidate_positions.append(survivor.rect.center)
1535
- for buddy in buddies_on_screen:
1536
- candidate_positions.append(buddy.rect.center)
1537
- candidate_positions.append(player.rect.center)
1538
- if candidate_positions:
1539
- target = min(
1540
- candidate_positions,
1541
- key=lambda pos: math.hypot(
1542
- pos[0] - zombie.x, pos[1] - zombie.y
1543
- ),
1544
- )
1545
- nearby_candidates = _nearby_zombies(idx)
1546
- zombie_search_radius = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius + 120
1547
- nearby_walls = walls_near((zombie.x, zombie.y), zombie_search_radius)
1548
- zombie.update(
1549
- target,
1550
- nearby_walls,
1551
- nearby_candidates,
1552
- footprints=game_data.state.footprints,
1553
- )
1554
-
1555
-
1556
- def check_interactions(
1557
- game_data: GameData, config: dict[str, Any]
1558
- ) -> pygame.sprite.Sprite | None:
1559
- """Check and handle interactions between entities."""
1560
- player = game_data.player
1561
- assert player is not None
1562
- car = game_data.car
1563
- zombie_group = game_data.groups.zombie_group
1564
- all_sprites = game_data.groups.all_sprites
1565
- survivor_group = game_data.groups.survivor_group
1566
- state = game_data.state
1567
- walkable_cells = game_data.areas.walkable_cells
1568
- outside_rects = game_data.areas.outside_rects
1569
- fuel = game_data.fuel
1570
- flashlights = game_data.flashlights or []
1571
- camera = game_data.camera
1572
- stage = game_data.stage
1573
- maintain_waiting_car_supply(game_data)
1574
- active_car = car if car and car.alive() else None
1575
- waiting_cars = game_data.waiting_cars
1576
- shrunk_car = get_shrunk_sprite(active_car, 0.8) if active_car else None
1577
-
1578
- car_interaction_radius = interaction_radius(CAR_WIDTH, CAR_HEIGHT)
1579
- fuel_interaction_radius = interaction_radius(FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT)
1580
- flashlight_interaction_radius = interaction_radius(
1581
- FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT
1582
- )
1583
-
1584
- def player_near_point(point: tuple[float, float], radius: float) -> bool:
1585
- return math.hypot(point[0] - player.x, point[1] - player.y) <= radius
1586
-
1587
- def player_near_sprite(
1588
- sprite_obj: pygame.sprite.Sprite | None, radius: float
1589
- ) -> bool:
1590
- return bool(
1591
- sprite_obj
1592
- and sprite_obj.alive()
1593
- and player_near_point(sprite_obj.rect.center, radius)
1594
- )
1595
-
1596
- def player_near_car(car_obj: Car | None) -> bool:
1597
- return player_near_sprite(car_obj, car_interaction_radius)
1598
-
1599
- # Fuel pickup
1600
- if fuel and fuel.alive() and not state.has_fuel and not player.in_car:
1601
- if player_near_point(fuel.rect.center, fuel_interaction_radius):
1602
- state.has_fuel = True
1603
- state.fuel_message_until = 0
1604
- state.hint_expires_at = 0
1605
- state.hint_target_type = None
1606
- fuel.kill()
1607
- game_data.fuel = None
1608
- print("Fuel acquired!")
1609
-
1610
- # Flashlight pickup
1611
- if not player.in_car:
1612
- for flashlight in list(flashlights):
1613
- if not flashlight.alive():
1614
- continue
1615
- if player_near_point(
1616
- flashlight.rect.center, flashlight_interaction_radius
1617
- ):
1618
- state.flashlight_count += 1
1619
- state.hint_expires_at = 0
1620
- state.hint_target_type = None
1621
- flashlight.kill()
1622
- try:
1623
- flashlights.remove(flashlight)
1624
- except ValueError:
1625
- pass
1626
- print("Flashlight acquired!")
1627
- break
1628
-
1629
- sync_ambient_palette_with_flashlights(game_data)
1630
-
1631
- buddies = [
1632
- survivor
1633
- for survivor in survivor_group
1634
- if survivor.alive() and survivor.is_buddy and not survivor.rescued
1635
- ]
1636
-
1637
- # Buddy interactions (Stage 3)
1638
- if stage.buddy_required_count > 0 and buddies:
1639
- for buddy in list(buddies):
1640
- if not buddy.alive():
1641
- continue
1642
- buddy_on_screen = rect_visible_on_screen(camera, buddy.rect)
1643
- if not player.in_car:
1644
- dist_to_player = math.hypot(player.x - buddy.x, player.y - buddy.y)
1645
- if dist_to_player <= SURVIVOR_APPROACH_RADIUS:
1646
- buddy.set_following()
1647
- elif player.in_car and active_car and shrunk_car:
1648
- g = pygame.sprite.Group()
1649
- g.add(buddy)
1650
- if pygame.sprite.spritecollide(
1651
- shrunk_car, g, False, pygame.sprite.collide_circle
1652
- ):
1653
- prospective_passengers = state.survivors_onboard + 1
1654
- capacity_limit = state.survivor_capacity
1655
- if prospective_passengers > capacity_limit:
1656
- overload_damage = max(
1657
- 1,
1658
- int(active_car.max_health * SURVIVOR_OVERLOAD_DAMAGE_RATIO),
1659
- )
1660
- add_survivor_message(game_data, tr("survivors.too_many_aboard"))
1661
- active_car.take_damage(overload_damage)
1662
- state.buddy_onboard += 1
1663
- buddy.kill()
1664
- continue
1665
-
1666
- if buddy.alive() and pygame.sprite.spritecollide(
1667
- buddy, zombie_group, False, pygame.sprite.collide_circle
1668
- ):
1669
- if buddy_on_screen:
1670
- state.game_over_message = tr("game_over.scream")
1671
- state.game_over = True
1672
- state.game_over_at = state.game_over_at or pygame.time.get_ticks()
1673
- else:
1674
- if walkable_cells:
1675
- new_cell = RNG.choice(walkable_cells)
1676
- buddy.teleport(new_cell.center)
1677
- else:
1678
- buddy.teleport((LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2))
1679
- buddy.following = False
1680
-
1681
- # Player entering an active car already under control
1682
- if (
1683
- not player.in_car
1684
- and player_near_car(active_car)
1685
- and active_car
1686
- and active_car.health > 0
1687
- ):
1688
- if state.has_fuel:
1689
- player.in_car = True
1690
- all_sprites.remove(player)
1691
- state.hint_expires_at = 0
1692
- state.hint_target_type = None
1693
- print("Player entered car!")
1694
- else:
1695
- if not stage.survival_stage:
1696
- now_ms = state.elapsed_play_ms
1697
- state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
1698
- state.hint_target_type = "fuel"
1699
-
1700
- # Claim a waiting/parked car when the player finally reaches it
1701
- if not player.in_car and not active_car and waiting_cars:
1702
- claimed_car: Car | None = None
1703
- for parked_car in waiting_cars:
1704
- if player_near_car(parked_car):
1705
- claimed_car = parked_car
1706
- break
1707
- if claimed_car:
1708
- if state.has_fuel:
1709
- try:
1710
- game_data.waiting_cars.remove(claimed_car)
1711
- except ValueError:
1712
- pass
1713
- game_data.car = claimed_car
1714
- active_car = claimed_car
1715
- player.in_car = True
1716
- all_sprites.remove(player)
1717
- state.hint_expires_at = 0
1718
- state.hint_target_type = None
1719
- apply_passenger_speed_penalty(game_data)
1720
- maintain_waiting_car_supply(game_data)
1721
- print("Player claimed a waiting car!")
1722
- else:
1723
- if not stage.survival_stage:
1724
- now_ms = state.elapsed_play_ms
1725
- state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
1726
- state.hint_target_type = "fuel"
1727
-
1728
- # Bonus: collide a parked car while driving to repair/extend capabilities
1729
- if player.in_car and active_car and shrunk_car and waiting_cars:
1730
- waiting_group = pygame.sprite.Group(waiting_cars)
1731
- collided_waiters = pygame.sprite.spritecollide(
1732
- shrunk_car, waiting_group, False, pygame.sprite.collide_rect
1733
- )
1734
- if collided_waiters:
1735
- removed_any = False
1736
- capacity_increments = 0
1737
- for parked in collided_waiters:
1738
- if not parked.alive():
1739
- continue
1740
- parked.kill()
1741
- try:
1742
- game_data.waiting_cars.remove(parked)
1743
- except ValueError:
1744
- pass
1745
- active_car.health = active_car.max_health
1746
- active_car.update_color()
1747
- removed_any = True
1748
- if stage.rescue_stage:
1749
- capacity_increments += 1
1750
- if removed_any:
1751
- if capacity_increments:
1752
- increase_survivor_capacity(game_data, capacity_increments)
1753
- maintain_waiting_car_supply(game_data)
1754
-
1755
- # Car hitting zombies
1756
- if player.in_car and active_car and active_car.health > 0 and shrunk_car:
1757
- zombies_hit = pygame.sprite.spritecollide(shrunk_car, zombie_group, True)
1758
- if zombies_hit:
1759
- active_car.take_damage(CAR_ZOMBIE_DAMAGE * len(zombies_hit))
1760
-
1761
- if (
1762
- stage.rescue_stage
1763
- and player.in_car
1764
- and active_car
1765
- and shrunk_car
1766
- and survivor_group
1767
- ):
1768
- boarded = pygame.sprite.spritecollide(
1769
- shrunk_car, survivor_group, True, pygame.sprite.collide_circle
1770
- )
1771
- if boarded:
1772
- state.survivors_onboard += len(boarded)
1773
- apply_passenger_speed_penalty(game_data)
1774
- capacity_limit = state.survivor_capacity
1775
- if state.survivors_onboard > capacity_limit:
1776
- overload_damage = max(
1777
- 1,
1778
- int(active_car.max_health * SURVIVOR_OVERLOAD_DAMAGE_RATIO),
1779
- )
1780
- add_survivor_message(game_data, tr("survivors.too_many_aboard"))
1781
- active_car.take_damage(overload_damage)
1782
-
1783
- if stage.rescue_stage:
1784
- handle_survivor_zombie_collisions(game_data, config)
1785
-
1786
- # Handle car destruction
1787
- if car and car.alive() and car.health <= 0:
1788
- car_destroyed_pos = car.rect.center
1789
- car.kill()
1790
- if stage.rescue_stage:
1791
- drop_survivors_from_car(game_data, car_destroyed_pos)
1792
- if player.in_car:
1793
- player.in_car = False
1794
- player.x, player.y = car_destroyed_pos[0], car_destroyed_pos[1]
1795
- player.rect.center = (int(player.x), int(player.y))
1796
- if player not in all_sprites:
1797
- all_sprites.add(player, layer=2)
1798
- print("Car destroyed! Player ejected.")
1799
-
1800
- # Clear active car and let the player hunt for another waiting car.
1801
- game_data.car = None
1802
- state.survivor_capacity = SURVIVOR_MAX_SAFE_PASSENGERS
1803
- apply_passenger_speed_penalty(game_data)
1804
-
1805
- # Bring back the buddies near the player after losing the car
1806
- respawn_buddies_near_player(game_data)
1807
- maintain_waiting_car_supply(game_data)
1808
-
1809
- # Player getting caught by zombies
1810
- if not player.in_car and player in all_sprites:
1811
- shrunk_player = get_shrunk_sprite(player, 0.8)
1812
- if pygame.sprite.spritecollide(
1813
- shrunk_player, zombie_group, False, pygame.sprite.collide_circle
1814
- ):
1815
- if not state.game_over:
1816
- state.game_over = True
1817
- state.game_over_at = pygame.time.get_ticks()
1818
- state.game_over_message = tr("game_over.scream")
1819
-
1820
- # Player escaping on foot after dawn (Stage 5)
1821
- if (
1822
- stage.survival_stage
1823
- and state.dawn_ready
1824
- and not player.in_car
1825
- and outside_rects
1826
- and any(outside.collidepoint(player.rect.center) for outside in outside_rects)
1827
- ):
1828
- state.game_won = True
1829
-
1830
- # Player escaping the level
1831
- if player.in_car and car and car.alive() and state.has_fuel:
1832
- buddies_following = [
1833
- survivor
1834
- for survivor in survivor_group
1835
- if survivor.alive()
1836
- and survivor.is_buddy
1837
- and survivor.following
1838
- and not survivor.rescued
1839
- ]
1840
- buddy_ready = True
1841
- if stage.buddy_required_count > 0:
1842
- total_ready = (
1843
- state.buddy_rescued + state.buddy_onboard + len(buddies_following)
1844
- )
1845
- buddy_ready = total_ready >= stage.buddy_required_count
1846
- if buddy_ready and any(
1847
- outside.collidepoint(car.rect.center) for outside in outside_rects
1848
- ):
1849
- if stage.buddy_required_count > 0:
1850
- rescued_now = state.buddy_onboard + len(buddies_following)
1851
- state.buddy_rescued = min(
1852
- stage.buddy_required_count,
1853
- state.buddy_rescued + rescued_now,
1854
- )
1855
- state.buddy_onboard = 0
1856
- for buddy in buddies_following:
1857
- buddy.mark_rescued()
1858
- if stage.rescue_stage and state.survivors_onboard:
1859
- state.survivors_rescued += state.survivors_onboard
1860
- state.survivors_onboard = 0
1861
- state.next_overload_check_ms = 0
1862
- apply_passenger_speed_penalty(game_data)
1863
- state.game_won = True
1864
-
1865
- # Return fog of view target
1866
- if not state.game_over and not state.game_won:
1867
- return car if player.in_car and car and car.alive() else player
1868
- return None
1869
-
1870
-
1871
- def set_ambient_palette(
1872
- game_data: GameData, key: str, *, force: bool = False
1873
- ) -> None:
1874
- """Apply a named ambient palette to all walls in the level."""
1875
-
1876
- palette = get_environment_palette(key)
1877
- state = game_data.state
1878
- if not force and state.ambient_palette_key == key:
1879
- return
1880
-
1881
- state.ambient_palette_key = key
1882
- _apply_palette_to_walls(game_data, palette, force=True)
1883
-
1884
-
1885
- def sync_ambient_palette_with_flashlights(
1886
- game_data: GameData, *, force: bool = False
1887
- ) -> None:
1888
- """Sync the ambient palette with the player's flashlight inventory."""
1889
-
1890
- state = game_data.state
1891
- if state.dawn_ready:
1892
- set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=force)
1893
- return
1894
- key = ambient_palette_key_for_flashlights(state.flashlight_count)
1895
- set_ambient_palette(game_data, key, force=force)
1896
-
1897
-
1898
- def _apply_palette_to_walls(
1899
- game_data: GameData,
1900
- palette,
1901
- *,
1902
- force: bool = False,
1903
- ) -> None:
1904
- if not hasattr(game_data, "groups") or not hasattr(game_data.groups, "wall_group"):
1905
- return
1906
- wall_group = game_data.groups.wall_group
1907
- for wall in wall_group:
1908
- if not hasattr(wall, "set_palette_colors"):
1909
- continue
1910
- category = getattr(wall, "palette_category", "inner_wall")
1911
- if category == "outer_wall":
1912
- color = palette.outer_wall
1913
- border_color = palette.outer_wall_border
1914
- else:
1915
- color = palette.inner_wall
1916
- border_color = palette.inner_wall_border
1917
- wall.set_palette_colors(color=color, border_color=border_color, force=force)