zombie-escape 1.1.4__tar.gz → 1.2.0__tar.gz

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 (29) hide show
  1. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/PKG-INFO +11 -11
  2. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/README.md +10 -10
  3. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/__about__.py +1 -1
  4. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/constants.py +1 -1
  5. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/gameplay/logic.py +291 -111
  6. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/models.py +3 -1
  7. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/render.py +19 -1
  8. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/screens/gameplay.py +24 -8
  9. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/zombie_escape.py +9 -0
  10. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/.gitignore +0 -0
  11. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/LICENSE.txt +0 -0
  12. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/pyproject.toml +0 -0
  13. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/__init__.py +0 -0
  14. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/assets/fonts/Silkscreen-Regular.ttf +0 -0
  15. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/assets/fonts/misaki_gothic.ttf +0 -0
  16. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/colors.py +0 -0
  17. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/config.py +0 -0
  18. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/entities.py +0 -0
  19. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/font_utils.py +0 -0
  20. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/gameplay/__init__.py +0 -0
  21. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/level_blueprints.py +0 -0
  22. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/locales/ui.en.json +0 -0
  23. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/locales/ui.ja.json +0 -0
  24. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/localization.py +0 -0
  25. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/render_assets.py +0 -0
  26. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/screens/__init__.py +0 -0
  27. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/screens/game_over.py +0 -0
  28. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/screens/settings.py +0 -0
  29. {zombie_escape-1.1.4 → zombie_escape-1.2.0}/src/zombie_escape/screens/title.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zombie-escape
3
- Version: 1.1.4
3
+ Version: 1.2.0
4
4
  Summary: Top-down zombie survival game built with pygame.
5
5
  Project-URL: Homepage, https://github.com/tos-kamiya/zombie-escape
6
6
  Author-email: Toshihiro Kamiya <kamiya@mbj.nifty.com>
@@ -65,29 +65,29 @@ Open **Settings** from the title to toggle gameplay assists:
65
65
 
66
66
  At the title screen you can pick a stage:
67
67
 
68
- - **Stage 1: Find the Car** — locate the car and drive out.
69
- - **Stage 2: Fuel Run** — find a fuel can first, pick it up, then find the car and escape.
70
- - **Stage 3: Rescue Buddy** — find your stranded buddy, grab fuel, pick them up with the car, then escape together.
71
- - **Stage 4: Evacuate Survivors** — find the car, gather nearby civilians, and escape before zombies reach them.
68
+ - **Stage 1: Find the Car** — locate the car and drive out (you already start with fuel).
69
+ - **Stage 2: Fuel Run** — you start with no fuel; find a fuel can first, pick it up, then find the car and escape.
70
+ - **Stage 3: Rescue Buddy** — same fuel hunt as Stage 2 (you begin empty) plus grab your buddy, pick them up with the car, then escape together.
71
+ - **Stage 4: Evacuate Survivors** — start fueled, find the car, gather nearby civilians, and escape before zombies reach them. Stage 4 sprinkles extra parked cars across the map; slamming into one while already driving fully repairs your current ride and adds five more safe seats.
72
72
 
73
73
  An objective reminder is shown at the top-left during play.
74
74
 
75
75
  ### Characters/Items
76
76
 
77
- - **Player:** A blue circle. Controlled with the WASD or arrow keys.
77
+ - **Player:** A blue circle. Controlled with the WASD or arrow keys. When carrying fuel a tiny yellow square appears near the sprite so you can immediately see whether you're ready to drive.
78
78
  - **Zombie:** A red circle. Will chase the player (or car) once detected.
79
79
  - When out of sight, the zombie's movement mode will randomly switch every certain time (moving horizontally/vertically only, side-to-side movement, random movement, etc.).
80
80
  - **Car:** A yellow rectangle. The player can enter by making contact with it.
81
81
  - The car has durability. Durability decreases when colliding with internal walls or hitting zombies.
82
- - If durability reaches 0, the car is destroyed, and the player is ejected.
83
- - When the car is destroyed, a **new car will respawn** at a random location within the stage.
84
- - After roughly 5 minutes of play, a small triangle near the player points toward the objective: fuel first (Stage 2 before pickup), car after fuel is collected (Stage 2), or car directly (Stage 1).
82
+ - If durability reaches 0, the car is destroyed and you are dumped on foot; you must track down another parked car hidden in the level.
83
+ - When you're already driving, ramming a parked car instantly restores your current car's health. On Stage 4 this also increases the safe passenger limit by five.
84
+ - After roughly 5 minutes of play, a small triangle near the player points toward the objective: fuel first (Stage 2 before pickup), car after fuel is collected (Stage 2/3), or car directly (Stage 1/4).
85
85
  - **Walls:** Outer walls are gray; inner walls are beige.
86
86
  - **Outer Walls:** Walls surrounding the stage that are nearly indestructible. Each side has at least three openings (exits).
87
87
  - **Inner Walls:** Beige walls randomly placed inside the building. Inner wall segments each have durability. **The player can break these walls** by repeatedly colliding with a segment to reduce its durability; when it reaches 0, the segment is destroyed and disappears. The car cannot break walls.
88
88
  - **Flashlight:** Picking one up boosts your visible radius by 35%.
89
89
  - **Steel Beam (optional):** A square post with crossed diagonals; same collision as inner walls but with triple durability. Spawns independently of inner walls (may overlap them). If an inner wall covers a beam, the beam appears once the wall is destroyed.
90
- - **Fuel Can (Stage 2):** A yellow jerrycan. Pick it up before driving the car.
90
+ - **Fuel Can (Stages 2 & 3):** A yellow jerrycan that only spawns on the fuel-run stages. Pick it up before driving the car; once collected the on-player indicator appears until you refuel the car.
91
91
  - **Buddy (Stage 3):** A green circle survivor who spawns somewhere in the building and waits.
92
92
  - Zombies only choose to pursue the buddy if they are on-screen; otherwise they ignore them.
93
93
  - If a zombie tags the buddy off-screen, the buddy quietly respawns somewhere else instead of ending the run.
@@ -95,7 +95,7 @@ An objective reminder is shown at the top-left during play.
95
95
  - **Survivors (Stage 4):** Light blue civilians scattered indoors.
96
96
  - They stand still until you get close, then shuffle toward you at about one-third of player speed.
97
97
  - Zombies can convert them if both are on-screen; the survivor shouts a line and turns instantly.
98
- - They only board the car; up to five ride safely, each slowing the car. Going over the limit damages the car and ejects everyone back into the building.
98
+ - They only board the car; your safe capacity starts at five but grows by five each time you sideswipe a parked car while already driving. Speed loss is based on how full the car is relative to that capacity, so extra slots mean quicker getaways.
99
99
 
100
100
  ### Win/Lose Conditions
101
101
 
@@ -44,29 +44,29 @@ Open **Settings** from the title to toggle gameplay assists:
44
44
 
45
45
  At the title screen you can pick a stage:
46
46
 
47
- - **Stage 1: Find the Car** — locate the car and drive out.
48
- - **Stage 2: Fuel Run** — find a fuel can first, pick it up, then find the car and escape.
49
- - **Stage 3: Rescue Buddy** — find your stranded buddy, grab fuel, pick them up with the car, then escape together.
50
- - **Stage 4: Evacuate Survivors** — find the car, gather nearby civilians, and escape before zombies reach them.
47
+ - **Stage 1: Find the Car** — locate the car and drive out (you already start with fuel).
48
+ - **Stage 2: Fuel Run** — you start with no fuel; find a fuel can first, pick it up, then find the car and escape.
49
+ - **Stage 3: Rescue Buddy** — same fuel hunt as Stage 2 (you begin empty) plus grab your buddy, pick them up with the car, then escape together.
50
+ - **Stage 4: Evacuate Survivors** — start fueled, find the car, gather nearby civilians, and escape before zombies reach them. Stage 4 sprinkles extra parked cars across the map; slamming into one while already driving fully repairs your current ride and adds five more safe seats.
51
51
 
52
52
  An objective reminder is shown at the top-left during play.
53
53
 
54
54
  ### Characters/Items
55
55
 
56
- - **Player:** A blue circle. Controlled with the WASD or arrow keys.
56
+ - **Player:** A blue circle. Controlled with the WASD or arrow keys. When carrying fuel a tiny yellow square appears near the sprite so you can immediately see whether you're ready to drive.
57
57
  - **Zombie:** A red circle. Will chase the player (or car) once detected.
58
58
  - When out of sight, the zombie's movement mode will randomly switch every certain time (moving horizontally/vertically only, side-to-side movement, random movement, etc.).
59
59
  - **Car:** A yellow rectangle. The player can enter by making contact with it.
60
60
  - The car has durability. Durability decreases when colliding with internal walls or hitting zombies.
61
- - If durability reaches 0, the car is destroyed, and the player is ejected.
62
- - When the car is destroyed, a **new car will respawn** at a random location within the stage.
63
- - After roughly 5 minutes of play, a small triangle near the player points toward the objective: fuel first (Stage 2 before pickup), car after fuel is collected (Stage 2), or car directly (Stage 1).
61
+ - If durability reaches 0, the car is destroyed and you are dumped on foot; you must track down another parked car hidden in the level.
62
+ - When you're already driving, ramming a parked car instantly restores your current car's health. On Stage 4 this also increases the safe passenger limit by five.
63
+ - After roughly 5 minutes of play, a small triangle near the player points toward the objective: fuel first (Stage 2 before pickup), car after fuel is collected (Stage 2/3), or car directly (Stage 1/4).
64
64
  - **Walls:** Outer walls are gray; inner walls are beige.
65
65
  - **Outer Walls:** Walls surrounding the stage that are nearly indestructible. Each side has at least three openings (exits).
66
66
  - **Inner Walls:** Beige walls randomly placed inside the building. Inner wall segments each have durability. **The player can break these walls** by repeatedly colliding with a segment to reduce its durability; when it reaches 0, the segment is destroyed and disappears. The car cannot break walls.
67
67
  - **Flashlight:** Picking one up boosts your visible radius by 35%.
68
68
  - **Steel Beam (optional):** A square post with crossed diagonals; same collision as inner walls but with triple durability. Spawns independently of inner walls (may overlap them). If an inner wall covers a beam, the beam appears once the wall is destroyed.
69
- - **Fuel Can (Stage 2):** A yellow jerrycan. Pick it up before driving the car.
69
+ - **Fuel Can (Stages 2 & 3):** A yellow jerrycan that only spawns on the fuel-run stages. Pick it up before driving the car; once collected the on-player indicator appears until you refuel the car.
70
70
  - **Buddy (Stage 3):** A green circle survivor who spawns somewhere in the building and waits.
71
71
  - Zombies only choose to pursue the buddy if they are on-screen; otherwise they ignore them.
72
72
  - If a zombie tags the buddy off-screen, the buddy quietly respawns somewhere else instead of ending the run.
@@ -74,7 +74,7 @@ An objective reminder is shown at the top-left during play.
74
74
  - **Survivors (Stage 4):** Light blue civilians scattered indoors.
75
75
  - They stand still until you get close, then shuffle toward you at about one-third of player speed.
76
76
  - Zombies can convert them if both are on-screen; the survivor shouts a line and turns instantly.
77
- - They only board the car; up to five ride safely, each slowing the car. Going over the limit damages the car and ejects everyone back into the building.
77
+ - They only board the car; your safe capacity starts at five but grows by five each time you sideswipe a parked car while already driving. Speed loss is based on how full the car is relative to that capacity, so extra slots mean quicker getaways.
78
78
 
79
79
  ### Win/Lose Conditions
80
80
 
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "1.1.4"
4
+ __version__ = "1.2.0"
@@ -44,7 +44,7 @@ SURVIVOR_APPROACH_RADIUS = 48
44
44
  SURVIVOR_APPROACH_SPEED = PLAYER_SPEED * 0.35
45
45
  SURVIVOR_SPAWN_RATE = 0.07
46
46
  SURVIVOR_MAX_SAFE_PASSENGERS = 5
47
- SURVIVOR_SPEED_PENALTY_PER_PASSENGER = 0.104
47
+ SURVIVOR_SPEED_PENALTY_PER_PASSENGER = 0.08
48
48
  SURVIVOR_MIN_SPEED_FACTOR = 0.35
49
49
  SURVIVOR_OVERLOAD_DAMAGE_RATIO = 0.2
50
50
  SURVIVOR_MESSAGE_DURATION_MS = 2000
@@ -68,6 +68,8 @@ from ..entities import (
68
68
  Zombie,
69
69
  )
70
70
 
71
+ LOGICAL_SCREEN_RECT = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
72
+
71
73
  __all__ = [
72
74
  "create_zombie",
73
75
  "rect_for_cell",
@@ -80,8 +82,13 @@ __all__ = [
80
82
  "scatter_positions_on_walkable",
81
83
  "spawn_survivors",
82
84
  "update_survivors",
85
+ "alive_waiting_cars",
86
+ "nearest_waiting_car",
83
87
  "calculate_car_speed_for_passengers",
84
88
  "apply_passenger_speed_penalty",
89
+ "waiting_car_target_count",
90
+ "spawn_waiting_car",
91
+ "maintain_waiting_car_supply",
85
92
  "add_survivor_message",
86
93
  "random_survivor_conversion_line",
87
94
  "cleanup_survivor_messages",
@@ -91,7 +98,7 @@ __all__ = [
91
98
  "get_shrunk_sprite",
92
99
  "update_footprints",
93
100
  "initialize_game_state",
94
- "setup_player_and_car",
101
+ "setup_player_and_cars",
95
102
  "spawn_initial_zombies",
96
103
  "process_player_input",
97
104
  "update_entities",
@@ -246,6 +253,8 @@ def place_new_car(
246
253
  wall_group: pygame.sprite.Group,
247
254
  player: Player,
248
255
  walkable_cells: list[pygame.Rect],
256
+ *,
257
+ existing_cars: Sequence[Car] | None = None,
249
258
  ) -> Car | None:
250
259
  if not walkable_cells:
251
260
  return None
@@ -268,13 +277,23 @@ def place_new_car(
268
277
  temp_car, nearby_walls, collided=lambda s1, s2: s1.rect.colliderect(s2.rect)
269
278
  )
270
279
  collides_player = temp_rect.colliderect(player.rect.inflate(50, 50))
271
- if not collides_wall and not collides_player:
280
+ car_overlap = False
281
+ if existing_cars:
282
+ car_overlap = any(
283
+ temp_car.rect.colliderect(other.rect)
284
+ for other in existing_cars
285
+ if other and other.alive()
286
+ )
287
+ if not collides_wall and not collides_player and not car_overlap:
272
288
  return temp_car
273
289
  return None
274
290
 
275
291
 
276
292
  def place_fuel_can(
277
- walkable_cells: list[pygame.Rect], player: Player, *, car: Car | None = None
293
+ walkable_cells: list[pygame.Rect],
294
+ player: Player,
295
+ *,
296
+ cars: Sequence[Car] | None = None,
278
297
  ) -> FuelCan | None:
279
298
  """Pick a spawn spot for the fuel can away from the player (and car if given)."""
280
299
  if not walkable_cells:
@@ -290,14 +309,17 @@ def place_fuel_can(
290
309
  < min_player_dist
291
310
  ):
292
311
  continue
293
- if (
294
- car
295
- and math.hypot(
296
- cell.centerx - car.rect.centerx, cell.centery - car.rect.centery
297
- )
298
- < min_car_dist
299
- ):
300
- continue
312
+ if cars:
313
+ too_close = False
314
+ for parked_car in cars:
315
+ if math.hypot(
316
+ cell.centerx - parked_car.rect.centerx,
317
+ cell.centery - parked_car.rect.centery,
318
+ ) < min_car_dist:
319
+ too_close = True
320
+ break
321
+ if too_close:
322
+ continue
301
323
  return FuelCan(cell.centerx, cell.centery)
302
324
 
303
325
  # Fallback: drop near a random walkable cell
@@ -306,7 +328,10 @@ def place_fuel_can(
306
328
 
307
329
 
308
330
  def place_flashlight(
309
- walkable_cells: list[pygame.Rect], player: Player, *, car: Car | None = None
331
+ walkable_cells: list[pygame.Rect],
332
+ player: Player,
333
+ *,
334
+ cars: Sequence[Car] | None = None,
310
335
  ) -> Flashlight | None:
311
336
  """Pick a spawn spot for the flashlight away from the player (and car if given)."""
312
337
  if not walkable_cells:
@@ -322,14 +347,16 @@ def place_flashlight(
322
347
  < min_player_dist
323
348
  ):
324
349
  continue
325
- if (
326
- car
327
- and math.hypot(
328
- cell.centerx - car.rect.centerx, cell.centery - car.rect.centery
329
- )
330
- < min_car_dist
331
- ):
332
- continue
350
+ if cars:
351
+ if any(
352
+ math.hypot(
353
+ cell.centerx - parked.rect.centerx,
354
+ cell.centery - parked.rect.centery,
355
+ )
356
+ < min_car_dist
357
+ for parked in cars
358
+ ):
359
+ continue
333
360
  return Flashlight(cell.centerx, cell.centery)
334
361
 
335
362
  cell = random.choice(walkable_cells)
@@ -340,7 +367,7 @@ def place_flashlights(
340
367
  walkable_cells: list[pygame.Rect],
341
368
  player: Player,
342
369
  *,
343
- car: Car | None = None,
370
+ cars: Sequence[Car] | None = None,
344
371
  count: int = DEFAULT_FLASHLIGHT_SPAWN_COUNT,
345
372
  ) -> list[Flashlight]:
346
373
  """Spawn multiple flashlights using the single-place helper to spread them out."""
@@ -349,7 +376,7 @@ def place_flashlights(
349
376
  max_attempts = max(200, count * 80)
350
377
  while len(placed) < count and attempts < max_attempts:
351
378
  attempts += 1
352
- fl = place_flashlight(walkable_cells, player, car=car)
379
+ fl = place_flashlight(walkable_cells, player, cars=cars)
353
380
  if not fl:
354
381
  break
355
382
  # Avoid clustering too tightly
@@ -367,7 +394,10 @@ def place_flashlights(
367
394
 
368
395
 
369
396
  def place_companion(
370
- walkable_cells: list[pygame.Rect], player: Player, *, car: Car | None = None
397
+ walkable_cells: list[pygame.Rect],
398
+ player: Player,
399
+ *,
400
+ cars: Sequence[Car] | None = None,
371
401
  ) -> Companion | None:
372
402
  """Spawn the stranded buddy somewhere on a walkable tile away from the player and car."""
373
403
  if not walkable_cells:
@@ -383,14 +413,16 @@ def place_companion(
383
413
  < min_player_dist
384
414
  ):
385
415
  continue
386
- if (
387
- car
388
- and math.hypot(
389
- cell.centerx - car.rect.centerx, cell.centery - car.rect.centery
390
- )
391
- < min_car_dist
392
- ):
393
- continue
416
+ if cars:
417
+ if any(
418
+ math.hypot(
419
+ cell.centerx - parked.rect.centerx,
420
+ cell.centery - parked.rect.centery,
421
+ )
422
+ < min_car_dist
423
+ for parked in cars
424
+ ):
425
+ continue
394
426
  return Companion(cell.centerx, cell.centery)
395
427
 
396
428
  cell = random.choice(walkable_cells)
@@ -508,8 +540,12 @@ def update_survivors(game_data: GameData) -> None:
508
540
  other.rect.center = (int(other.x), int(other.y))
509
541
 
510
542
 
511
- def calculate_car_speed_for_passengers(passengers: int) -> float:
512
- penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * passengers
543
+ def calculate_car_speed_for_passengers(
544
+ passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
545
+ ) -> float:
546
+ cap = max(1, capacity)
547
+ load_ratio = max(0.0, passengers / cap)
548
+ penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * load_ratio
513
549
  penalty = min(0.95, max(0.0, penalty))
514
550
  adjusted = CAR_SPEED * (1 - penalty)
515
551
  return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, adjusted)
@@ -522,7 +558,86 @@ def apply_passenger_speed_penalty(game_data: GameData) -> None:
522
558
  if not game_data.stage.survivor_stage:
523
559
  car.speed = CAR_SPEED
524
560
  return
525
- car.speed = calculate_car_speed_for_passengers(game_data.state.survivors_onboard)
561
+ car.speed = calculate_car_speed_for_passengers(
562
+ game_data.state.survivors_onboard,
563
+ capacity=game_data.state.survivor_capacity,
564
+ )
565
+
566
+
567
+ def rect_visible_on_screen(camera: Camera | None, rect: pygame.Rect) -> bool:
568
+ if camera is None:
569
+ return False
570
+ return camera.apply_rect(rect).colliderect(LOGICAL_SCREEN_RECT)
571
+
572
+
573
+ def waiting_car_target_count(stage: Stage) -> int:
574
+ return 3 if stage.survivor_stage else 1
575
+
576
+
577
+ def spawn_waiting_car(game_data: GameData) -> Car | None:
578
+ """Attempt to place an additional parked car on the map."""
579
+ player = game_data.player
580
+ if not player:
581
+ return None
582
+ walkable_cells = game_data.areas.walkable_cells
583
+ if not walkable_cells:
584
+ return None
585
+ wall_group = game_data.groups.wall_group
586
+ all_sprites = game_data.groups.all_sprites
587
+ active_car = game_data.car if game_data.car and game_data.car.alive() else None
588
+ waiting = alive_waiting_cars(game_data)
589
+ obstacles: list[Car] = list(waiting)
590
+ if active_car:
591
+ obstacles.append(active_car)
592
+ camera = game_data.camera
593
+ offscreen_attempts = 6
594
+ while offscreen_attempts > 0:
595
+ new_car = place_new_car(
596
+ wall_group,
597
+ player,
598
+ walkable_cells,
599
+ existing_cars=obstacles,
600
+ )
601
+ if not new_car:
602
+ return None
603
+ if rect_visible_on_screen(camera, new_car.rect):
604
+ offscreen_attempts -= 1
605
+ continue
606
+ game_data.waiting_cars.append(new_car)
607
+ all_sprites.add(new_car, layer=1)
608
+ return new_car
609
+ return None
610
+
611
+
612
+ def maintain_waiting_car_supply(game_data: GameData) -> None:
613
+ """Ensure at least one parked car remains on the field."""
614
+ minimum = 1
615
+ current = len(alive_waiting_cars(game_data))
616
+ while current < minimum:
617
+ new_car = spawn_waiting_car(game_data)
618
+ if not new_car:
619
+ break
620
+ current += 1
621
+
622
+
623
+ def alive_waiting_cars(game_data: GameData) -> list[Car]:
624
+ """Return the list of parked cars that still exist, pruning any destroyed sprites."""
625
+ cars = [car for car in game_data.waiting_cars if car.alive()]
626
+ game_data.waiting_cars = cars
627
+ return cars
628
+
629
+
630
+ def nearest_waiting_car(
631
+ game_data: GameData, origin: tuple[float, float]
632
+ ) -> Car | None:
633
+ """Find the closest waiting car to an origin point."""
634
+ cars = alive_waiting_cars(game_data)
635
+ if not cars:
636
+ return None
637
+ return min(
638
+ cars,
639
+ key=lambda car: math.hypot(car.rect.centerx - origin[0], car.rect.centery - origin[1]),
640
+ )
526
641
 
527
642
 
528
643
  def add_survivor_message(game_data: GameData, text: str) -> None:
@@ -592,7 +707,6 @@ def handle_survivor_zombie_collisions(
592
707
  zombies.sort(key=lambda s: s.rect.centerx)
593
708
  zombie_xs = [z.rect.centerx for z in zombies]
594
709
  camera = game_data.camera
595
- screen_rect = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
596
710
 
597
711
  for survivor in list(survivor_group):
598
712
  if not survivor.alive():
@@ -622,7 +736,7 @@ def handle_survivor_zombie_collisions(
622
736
 
623
737
  if not collided:
624
738
  continue
625
- if not camera.apply_rect(survivor.rect).colliderect(screen_rect):
739
+ if not rect_visible_on_screen(camera, survivor.rect):
626
740
  continue
627
741
  survivor.kill()
628
742
  line = random_survivor_conversion_line()
@@ -749,6 +863,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
749
863
  survivors_onboard=0,
750
864
  survivors_rescued=0,
751
865
  survivor_messages=[],
866
+ survivor_capacity=SURVIVOR_MAX_SAFE_PASSENGERS,
752
867
  )
753
868
 
754
869
  # Create sprite groups
@@ -796,10 +911,13 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
796
911
  )
797
912
 
798
913
 
799
- def setup_player_and_car(
800
- game_data: GameData, layout_data: Mapping[str, list[pygame.Rect]]
801
- ) -> tuple[Player, Car]:
802
- """Create and position the player and car using blueprint candidates."""
914
+ def setup_player_and_cars(
915
+ game_data: GameData,
916
+ layout_data: Mapping[str, list[pygame.Rect]],
917
+ *,
918
+ car_count: int = 1,
919
+ ) -> tuple[Player, list[Car]]:
920
+ """Create the player plus one or more parked cars using blueprint candidates."""
803
921
  all_sprites = game_data.groups.all_sprites
804
922
  walkable_cells: list[pygame.Rect] = layout_data["walkable_cells"]
805
923
 
@@ -813,31 +931,38 @@ def setup_player_and_car(
813
931
  player_pos = pick_center(layout_data["player_cells"] or walkable_cells)
814
932
  player = Player(*player_pos)
815
933
 
816
- # Place car away from player
817
- car_candidates = layout_data["car_cells"] or walkable_cells
818
- car_pos = None
819
- for attempt in range(200):
820
- candidate = random.choice(car_candidates)
821
- if (
822
- math.hypot(
823
- candidate.centerx - player_pos[0], candidate.centery - player_pos[1]
824
- )
825
- >= 400
826
- ):
827
- car_pos = candidate.center
934
+ car_candidates = list(layout_data["car_cells"] or walkable_cells)
935
+ waiting_cars: list[Car] = []
936
+
937
+ def _pick_car_position() -> tuple[int, int]:
938
+ """Favor distant cells for the first car, otherwise fall back to random picks."""
939
+ if not car_candidates:
940
+ return (player_pos[0] + 200, player_pos[1])
941
+ random.shuffle(car_candidates)
942
+ for candidate in car_candidates:
943
+ if (
944
+ math.hypot(
945
+ candidate.centerx - player_pos[0],
946
+ candidate.centery - player_pos[1],
947
+ )
948
+ >= 400
949
+ ):
950
+ car_candidates.remove(candidate)
951
+ return candidate.center
952
+ # No far-enough cells found; pick the first available
953
+ choice = car_candidates.pop()
954
+ return choice.center
955
+
956
+ for idx in range(max(1, car_count)):
957
+ car_pos = _pick_car_position()
958
+ car = Car(*car_pos)
959
+ waiting_cars.append(car)
960
+ all_sprites.add(car, layer=1)
961
+ if not car_candidates:
828
962
  break
829
- if car_pos is None and car_candidates:
830
- car_pos = random.choice(car_candidates).center
831
- elif car_pos is None:
832
- car_pos = (player_pos[0] + 200, player_pos[1]) # Fallback
833
-
834
- car = Car(*car_pos)
835
963
 
836
- # Add to sprite groups
837
964
  all_sprites.add(player, layer=2)
838
- all_sprites.add(car, layer=1)
839
-
840
- return player, car
965
+ return player, waiting_cars
841
966
 
842
967
 
843
968
  def spawn_initial_zombies(
@@ -878,7 +1003,7 @@ def spawn_initial_zombies(
878
1003
 
879
1004
 
880
1005
  def process_player_input(
881
- keys: Sequence[bool], player: Player, car: Car
1006
+ keys: Sequence[bool], player: Player, car: Car | None
882
1007
  ) -> tuple[float, float, float, float]:
883
1008
  """Process keyboard input and return movement deltas."""
884
1009
  dx_input, dy_input = 0, 0
@@ -893,7 +1018,7 @@ def process_player_input(
893
1018
 
894
1019
  player_dx, player_dy, car_dx, car_dy = 0, 0, 0, 0
895
1020
 
896
- if player.in_car and car.alive():
1021
+ if player.in_car and car and car.alive():
897
1022
  target_speed = getattr(car, "speed", CAR_SPEED)
898
1023
  move_len = math.hypot(dx_input, dy_input)
899
1024
  if move_len > 0:
@@ -925,32 +1050,35 @@ def update_entities(
925
1050
  player = game_data.player
926
1051
  assert player is not None
927
1052
  car = game_data.car
928
- assert car is not None
929
1053
  companion = game_data.companion
930
1054
  wall_group = game_data.groups.wall_group
931
1055
  all_sprites = game_data.groups.all_sprites
932
1056
  zombie_group = game_data.groups.zombie_group
933
1057
  camera = game_data.camera
934
1058
  stage = game_data.stage
1059
+ active_car = car if car and car.alive() else None
935
1060
 
936
1061
  # Update player/car movement
937
- if player.in_car and car.alive():
938
- car.move(car_dx, car_dy, wall_group)
939
- player.rect.center = car.rect.center
940
- player.x, player.y = car.x, car.y
1062
+ if player.in_car and active_car:
1063
+ active_car.move(car_dx, car_dy, wall_group)
1064
+ player.rect.center = active_car.rect.center
1065
+ player.x, player.y = active_car.x, active_car.y
941
1066
  elif not player.in_car:
942
1067
  # Ensure player is in all_sprites if not in car
943
1068
  if player not in all_sprites:
944
1069
  all_sprites.add(player, layer=2)
945
1070
  player.move(player_dx, player_dy, wall_group)
1071
+ else:
1072
+ # Player flagged as in-car but car is gone; drop them back to foot control
1073
+ player.in_car = False
946
1074
 
947
1075
  # Update camera
948
- target_for_camera = car if player.in_car and car.alive() else player
1076
+ target_for_camera = active_car if player.in_car and active_car else player
949
1077
  camera.update(target_for_camera)
950
1078
 
951
1079
  # Update companion (Stage 3 follow logic)
952
1080
  if companion and companion.alive() and not companion.rescued:
953
- follow_target = car if player.in_car and car.alive() else player
1081
+ follow_target = active_car if player.in_car and active_car else player
954
1082
  companion.update_follow(follow_target.rect.center, wall_group)
955
1083
  if companion not in all_sprites:
956
1084
  all_sprites.add(companion, layer=2)
@@ -971,24 +1099,23 @@ def update_entities(
971
1099
 
972
1100
  # Update zombies
973
1101
  target_center = (
974
- car.rect.center if player.in_car and car.alive() else player.rect.center
1102
+ active_car.rect.center if player.in_car and active_car else player.rect.center
975
1103
  )
976
1104
  companion_on_screen = False
977
- screen_rect = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
978
1105
  if (
979
1106
  game_data.stage.requires_companion
980
1107
  and companion
981
1108
  and companion.alive()
982
1109
  and not companion.rescued
983
1110
  ):
984
- companion_on_screen = camera.apply_rect(companion.rect).colliderect(screen_rect)
1111
+ companion_on_screen = rect_visible_on_screen(camera, companion.rect)
985
1112
 
986
1113
  survivors_on_screen: list[Survivor] = []
987
1114
  if stage.survivor_stage:
988
1115
  survivor_group = game_data.groups.survivor_group
989
1116
  for survivor in survivor_group:
990
1117
  if getattr(survivor, "alive", lambda: False)():
991
- if camera.apply_rect(survivor.rect).colliderect(screen_rect):
1118
+ if rect_visible_on_screen(camera, survivor.rect):
992
1119
  survivors_on_screen.append(survivor)
993
1120
 
994
1121
  zombies_sorted: list[Zombie] = sorted(list(zombie_group), key=lambda z: z.x)
@@ -1024,7 +1151,7 @@ def update_entities(
1024
1151
  target = companion.rect.center
1025
1152
 
1026
1153
  if stage.survivor_stage:
1027
- zombie_on_screen = camera.apply_rect(zombie.rect).colliderect(screen_rect)
1154
+ zombie_on_screen = rect_visible_on_screen(camera, zombie.rect)
1028
1155
  if zombie_on_screen:
1029
1156
  candidate_positions: list[tuple[int, int]] = []
1030
1157
  for survivor in survivors_on_screen:
@@ -1050,10 +1177,8 @@ def check_interactions(
1050
1177
  player = game_data.player
1051
1178
  assert player is not None
1052
1179
  car = game_data.car
1053
- assert car is not None
1054
1180
  companion = game_data.companion
1055
1181
  zombie_group = game_data.groups.zombie_group
1056
- wall_group = game_data.groups.wall_group
1057
1182
  all_sprites = game_data.groups.all_sprites
1058
1183
  survivor_group = game_data.groups.survivor_group
1059
1184
  state = game_data.state
@@ -1063,6 +1188,9 @@ def check_interactions(
1063
1188
  flashlights = game_data.flashlights or []
1064
1189
  camera = game_data.camera
1065
1190
  stage = game_data.stage
1191
+ maintain_waiting_car_supply(game_data)
1192
+ active_car = car if car and car.alive() else None
1193
+ waiting_cars = game_data.waiting_cars
1066
1194
 
1067
1195
  # Fuel pickup
1068
1196
  if fuel and fuel.alive() and not state.has_fuel and not player.in_car:
@@ -1104,8 +1232,7 @@ def check_interactions(
1104
1232
  )
1105
1233
  if companion_active:
1106
1234
  assert companion is not None
1107
- screen_rect = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
1108
- companion_on_screen = camera.apply_rect(companion.rect).colliderect(screen_rect)
1235
+ companion_on_screen = rect_visible_on_screen(camera, companion.rect)
1109
1236
 
1110
1237
  # Companion interactions (Stage 3)
1111
1238
  if companion_active and stage.requires_companion:
@@ -1113,10 +1240,10 @@ def check_interactions(
1113
1240
  if not player.in_car:
1114
1241
  if pygame.sprite.collide_circle(companion, player):
1115
1242
  companion.set_following()
1116
- elif player.in_car and car.alive():
1243
+ elif player.in_car and active_car:
1117
1244
  g = pygame.sprite.Group()
1118
1245
  g.add(companion)
1119
- if pygame.sprite.spritecollide(get_shrunk_sprite(car, 0.8), g, False):
1246
+ if pygame.sprite.spritecollide(get_shrunk_sprite(active_car, 0.8), g, False):
1120
1247
  state.companion_rescued = True
1121
1248
  companion.mark_rescued()
1122
1249
  companion.kill()
@@ -1140,13 +1267,18 @@ def check_interactions(
1140
1267
  companion.teleport((LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2))
1141
1268
  companion.following = False
1142
1269
 
1143
- shrunk_car = get_shrunk_sprite(car, 0.8) if car else None
1270
+ player_group = pygame.sprite.Group()
1271
+ player_group.add(player)
1144
1272
 
1145
- # Player entering car
1146
- if not player.in_car and car.alive() and car.health > 0:
1147
- g = pygame.sprite.Group()
1148
- g.add(player)
1149
- if pygame.sprite.spritecollide(shrunk_car, g, False):
1273
+ # Player entering an active car already under control
1274
+ if (
1275
+ not player.in_car
1276
+ and active_car
1277
+ and active_car.alive()
1278
+ and active_car.health > 0
1279
+ ):
1280
+ shrunk_active = get_shrunk_sprite(active_car, 0.8)
1281
+ if pygame.sprite.spritecollide(shrunk_active, player_group, False):
1150
1282
  if state.has_fuel:
1151
1283
  player.in_car = True
1152
1284
  all_sprites.remove(player)
@@ -1156,19 +1288,72 @@ def check_interactions(
1156
1288
  else:
1157
1289
  now_ms = state.elapsed_play_ms
1158
1290
  state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
1159
- # Keep hint timing unchanged so the car visit doesn't immediately reveal fuel
1160
1291
  state.hint_target_type = "fuel"
1161
1292
 
1293
+ # Claim a waiting/parked car when the player finally reaches it
1294
+ if not player.in_car and not active_car and waiting_cars:
1295
+ claimed_car: Car | None = None
1296
+ for parked_car in waiting_cars:
1297
+ shrunk_waiting = get_shrunk_sprite(parked_car, 0.8)
1298
+ if pygame.sprite.spritecollide(shrunk_waiting, player_group, False):
1299
+ claimed_car = parked_car
1300
+ break
1301
+ if claimed_car:
1302
+ if state.has_fuel:
1303
+ try:
1304
+ game_data.waiting_cars.remove(claimed_car)
1305
+ except ValueError:
1306
+ pass
1307
+ game_data.car = claimed_car
1308
+ active_car = claimed_car
1309
+ player.in_car = True
1310
+ all_sprites.remove(player)
1311
+ state.hint_expires_at = 0
1312
+ state.hint_target_type = None
1313
+ apply_passenger_speed_penalty(game_data)
1314
+ maintain_waiting_car_supply(game_data)
1315
+ print("Player claimed a waiting car!")
1316
+ else:
1317
+ now_ms = state.elapsed_play_ms
1318
+ state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
1319
+ state.hint_target_type = "fuel"
1320
+
1321
+ shrunk_car = get_shrunk_sprite(active_car, 0.8) if active_car else None
1322
+
1323
+ # Bonus: collide a parked car while driving to repair/extend capabilities
1324
+ if player.in_car and active_car and shrunk_car and waiting_cars:
1325
+ waiting_group = pygame.sprite.Group(waiting_cars)
1326
+ collided_waiters = pygame.sprite.spritecollide(
1327
+ shrunk_car, waiting_group, False, pygame.sprite.collide_rect
1328
+ )
1329
+ if collided_waiters:
1330
+ removed_any = False
1331
+ for parked in collided_waiters:
1332
+ if not parked.alive():
1333
+ continue
1334
+ parked.kill()
1335
+ try:
1336
+ game_data.waiting_cars.remove(parked)
1337
+ except ValueError:
1338
+ pass
1339
+ active_car.health = active_car.max_health
1340
+ active_car.update_color()
1341
+ removed_any = True
1342
+ if stage.survivor_stage:
1343
+ state.survivor_capacity += SURVIVOR_MAX_SAFE_PASSENGERS
1344
+ if removed_any:
1345
+ maintain_waiting_car_supply(game_data)
1346
+
1162
1347
  # Car hitting zombies
1163
- if player.in_car and car.alive() and car.health > 0:
1348
+ if player.in_car and active_car and active_car.health > 0 and shrunk_car:
1164
1349
  zombies_hit = pygame.sprite.spritecollide(shrunk_car, zombie_group, True)
1165
1350
  if zombies_hit:
1166
- car.take_damage(CAR_ZOMBIE_DAMAGE * len(zombies_hit))
1351
+ active_car.take_damage(CAR_ZOMBIE_DAMAGE * len(zombies_hit))
1167
1352
 
1168
1353
  if (
1169
1354
  stage.survivor_stage
1170
1355
  and player.in_car
1171
- and car.alive()
1356
+ and active_car
1172
1357
  and shrunk_car
1173
1358
  and survivor_group
1174
1359
  ):
@@ -1178,18 +1363,20 @@ def check_interactions(
1178
1363
  if boarded:
1179
1364
  state.survivors_onboard += len(boarded)
1180
1365
  apply_passenger_speed_penalty(game_data)
1181
- if state.survivors_onboard > SURVIVOR_MAX_SAFE_PASSENGERS:
1366
+ capacity_limit = state.survivor_capacity
1367
+ if state.survivors_onboard > capacity_limit:
1182
1368
  overload_damage = max(
1183
- 1, int(game_data.car.max_health * SURVIVOR_OVERLOAD_DAMAGE_RATIO)
1369
+ 1,
1370
+ int(active_car.max_health * SURVIVOR_OVERLOAD_DAMAGE_RATIO),
1184
1371
  )
1185
1372
  add_survivor_message(game_data, _("survivors.too_many_aboard"))
1186
- game_data.car.take_damage(overload_damage)
1373
+ active_car.take_damage(overload_damage)
1187
1374
 
1188
1375
  if stage.survivor_stage:
1189
1376
  handle_survivor_zombie_collisions(game_data, config)
1190
1377
 
1191
1378
  # Handle car destruction
1192
- if car.alive() and car.health <= 0:
1379
+ if car and car.alive() and car.health <= 0:
1193
1380
  car_destroyed_pos = car.rect.center
1194
1381
  car.kill()
1195
1382
  if stage.survivor_stage:
@@ -1202,21 +1389,14 @@ def check_interactions(
1202
1389
  all_sprites.add(player, layer=2)
1203
1390
  print("Car destroyed! Player ejected.")
1204
1391
 
1392
+ # Clear active car and let the player hunt for another waiting car.
1393
+ game_data.car = None
1394
+ state.survivor_capacity = SURVIVOR_MAX_SAFE_PASSENGERS
1395
+ apply_passenger_speed_penalty(game_data)
1396
+
1205
1397
  # Bring back the rescued companion near the player after losing the car
1206
1398
  respawn_rescued_companion_near_player(game_data)
1207
-
1208
- # Respawn car
1209
- new_car = place_new_car(wall_group, player, walkable_cells)
1210
- if new_car is None:
1211
- # Fallback: Try original car position or other strategies
1212
- new_car = Car(car.rect.centerx, car.rect.centery)
1213
-
1214
- if new_car is not None:
1215
- game_data.car = new_car # Update car reference
1216
- all_sprites.add(new_car, layer=1)
1217
- apply_passenger_speed_penalty(game_data)
1218
- else:
1219
- print("Error: Failed to respawn car anywhere!")
1399
+ maintain_waiting_car_supply(game_data)
1220
1400
 
1221
1401
  # Player getting caught by zombies
1222
1402
  if not player.in_car and player in all_sprites:
@@ -1230,7 +1410,7 @@ def check_interactions(
1230
1410
  state.game_over_message = _("game_over.scream")
1231
1411
 
1232
1412
  # Player escaping the level
1233
- if player.in_car and car.alive() and state.has_fuel:
1413
+ if player.in_car and car and car.alive() and state.has_fuel:
1234
1414
  companion_ready = not stage.requires_companion or state.companion_rescued
1235
1415
  if companion_ready and any(
1236
1416
  outside.collidepoint(car.rect.center) for outside in outside_rects
@@ -1244,5 +1424,5 @@ def check_interactions(
1244
1424
 
1245
1425
  # Return fog of view target
1246
1426
  if not state.game_over and not state.game_won:
1247
- return car if player.in_car and car.alive() else player
1427
+ return car if player.in_car and car and car.alive() else player
1248
1428
  return None
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
6
  from typing import TYPE_CHECKING
7
7
 
8
8
  import pygame
@@ -48,6 +48,7 @@ class ProgressState:
48
48
  survivors_onboard: int
49
49
  survivors_rescued: int
50
50
  survivor_messages: list
51
+ survivor_capacity: int
51
52
 
52
53
 
53
54
  @dataclass
@@ -74,6 +75,7 @@ class GameData:
74
75
  flashlights: list[Flashlight] | None = None
75
76
  player: Player | None = None
76
77
  car: Car | None = None
78
+ waiting_cars: list[Car] = field(default_factory=list)
77
79
  companion: Companion | None = None
78
80
 
79
81
 
@@ -445,10 +445,28 @@ def draw(
445
445
  pygame.draw.circle(screen, color, sr.center, assets.footprint_radius)
446
446
 
447
447
  screen_rect_inflated = screen.get_rect().inflate(100, 100)
448
+ player_screen_rect: pygame.Rect | None = None
448
449
  for entity in all_sprites:
449
450
  sprite_screen_rect = camera.apply_rect(entity.rect)
450
451
  if sprite_screen_rect.colliderect(screen_rect_inflated):
451
452
  screen.blit(entity.image, sprite_screen_rect)
453
+ if entity is player:
454
+ player_screen_rect = sprite_screen_rect
455
+
456
+ if player_screen_rect is None:
457
+ player_screen_rect = camera.apply_rect(player.rect)
458
+
459
+ if has_fuel and player_screen_rect and not player.in_car:
460
+ indicator_size = max(6, (assets.player_radius // 2) * 2)
461
+ padding = max(2, indicator_size // 4)
462
+ indicator_rect = pygame.Rect(
463
+ player_screen_rect.right - indicator_size + padding,
464
+ player_screen_rect.bottom - indicator_size + padding,
465
+ indicator_size,
466
+ indicator_size,
467
+ )
468
+ pygame.draw.rect(screen, YELLOW, indicator_rect)
469
+ pygame.draw.rect(screen, BLACK, indicator_rect, width=1)
452
470
 
453
471
  if hint_target and player:
454
472
  current_fov_scale = get_fog_scale(
@@ -532,7 +550,7 @@ def draw(
532
550
  and getattr(stage, "survivor_stage", False)
533
551
  and (survivors_onboard is not None)
534
552
  ):
535
- limit = SURVIVOR_MAX_SAFE_PASSENGERS
553
+ limit = getattr(state, "survivor_capacity", SURVIVOR_MAX_SAFE_PASSENGERS)
536
554
  objective_lines.append(
537
555
  _("objectives.survivors_onboard", count=survivors_onboard, limit=limit)
538
556
  )
@@ -41,9 +41,15 @@ def gameplay_screen(
41
41
  last_fov_target = None
42
42
 
43
43
  layout_data = logic.generate_level_from_blueprint(game_data, config)
44
- player, car = logic.setup_player_and_car(game_data, layout_data)
44
+ initial_waiting = 3 if stage.survivor_stage else 1
45
+ player, waiting_cars = logic.setup_player_and_cars(
46
+ game_data, layout_data, car_count=initial_waiting
47
+ )
45
48
  game_data.player = player
46
- game_data.car = car
49
+ game_data.waiting_cars = waiting_cars
50
+ game_data.car = None
51
+ # Only top up if initial placement spawned fewer than the intended baseline (shouldn't happen)
52
+ logic.maintain_waiting_car_supply(game_data)
47
53
  logic.apply_passenger_speed_penalty(game_data)
48
54
 
49
55
  if stage.survivor_stage:
@@ -59,7 +65,7 @@ def gameplay_screen(
59
65
 
60
66
  if stage.requires_fuel:
61
67
  fuel_can = logic.place_fuel_can(
62
- layout_data["walkable_cells"], player, car=car
68
+ layout_data["walkable_cells"], player, cars=game_data.waiting_cars
63
69
  )
64
70
  if fuel_can:
65
71
  game_data.fuel = fuel_can
@@ -68,7 +74,7 @@ def gameplay_screen(
68
74
  flashlights = logic.place_flashlights(
69
75
  layout_data["walkable_cells"],
70
76
  player,
71
- car=car,
77
+ cars=game_data.waiting_cars,
72
78
  count=max(1, flashlight_count),
73
79
  )
74
80
  game_data.flashlights = flashlights
@@ -76,7 +82,7 @@ def gameplay_screen(
76
82
 
77
83
  if stage.requires_companion:
78
84
  companion = logic.place_companion(
79
- layout_data["walkable_cells"], player, car=car
85
+ layout_data["walkable_cells"], player, cars=game_data.waiting_cars
80
86
  )
81
87
  if companion:
82
88
  game_data.companion = companion
@@ -222,10 +228,13 @@ def gameplay_screen(
222
228
  hint_expires_at = game_data.state.hint_expires_at
223
229
  hint_target_type = game_data.state.hint_target_type
224
230
 
231
+ active_car = game_data.car if game_data.car and game_data.car.alive() else None
225
232
  if hint_enabled:
226
233
  if not has_fuel and game_data.fuel and game_data.fuel.alive():
227
234
  target_type = "fuel"
228
- elif not player.in_car and game_data.car.alive():
235
+ elif not player.in_car and (
236
+ active_car or logic.alive_waiting_cars(game_data)
237
+ ):
229
238
  target_type = "car"
230
239
  else:
231
240
  target_type = None
@@ -246,8 +255,15 @@ def gameplay_screen(
246
255
  ):
247
256
  if target_type == "fuel" and game_data.fuel and game_data.fuel.alive():
248
257
  hint_target = game_data.fuel.rect.center
249
- elif target_type == "car" and game_data.car.alive():
250
- hint_target = game_data.car.rect.center
258
+ elif target_type == "car":
259
+ if active_car:
260
+ hint_target = active_car.rect.center
261
+ else:
262
+ waiting_target = logic.nearest_waiting_car(
263
+ game_data, (player.x, player.y)
264
+ )
265
+ if waiting_target:
266
+ hint_target = waiting_target.rect.center
251
267
 
252
268
  draw(
253
269
  render_assets,
@@ -29,6 +29,15 @@ from .screens.settings import settings_screen
29
29
  from .screens.title import title_screen
30
30
  from .gameplay.logic import calculate_car_speed_for_passengers
31
31
 
32
+ # Re-export the gameplay helpers constants for external callers/tests.
33
+ __all__ = [
34
+ "main",
35
+ "CAR_SPEED",
36
+ "SURVIVOR_MAX_SAFE_PASSENGERS",
37
+ "SURVIVOR_MIN_SPEED_FACTOR",
38
+ "calculate_car_speed_for_passengers",
39
+ ]
40
+
32
41
 
33
42
  # --- Main Entry Point ---
34
43
  def main() -> None:
File without changes
File without changes