zombie-escape 1.1.4__tar.gz → 1.2.1__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.1}/PKG-INFO +11 -11
  2. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/README.md +10 -10
  3. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/__about__.py +1 -1
  4. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/constants.py +4 -2
  5. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/gameplay/logic.py +305 -111
  6. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/models.py +4 -1
  7. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/render.py +19 -1
  8. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/screens/gameplay.py +29 -8
  9. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/zombie_escape.py +9 -0
  10. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/.gitignore +0 -0
  11. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/LICENSE.txt +0 -0
  12. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/pyproject.toml +0 -0
  13. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/__init__.py +0 -0
  14. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/assets/fonts/Silkscreen-Regular.ttf +0 -0
  15. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/assets/fonts/misaki_gothic.ttf +0 -0
  16. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/colors.py +0 -0
  17. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/config.py +0 -0
  18. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/entities.py +0 -0
  19. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/font_utils.py +0 -0
  20. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/gameplay/__init__.py +0 -0
  21. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/level_blueprints.py +0 -0
  22. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/locales/ui.en.json +0 -0
  23. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/locales/ui.ja.json +0 -0
  24. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/localization.py +0 -0
  25. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/render_assets.py +0 -0
  26. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/screens/__init__.py +0 -0
  27. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/screens/game_over.py +0 -0
  28. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/src/zombie_escape/screens/settings.py +0 -0
  29. {zombie_escape-1.1.4 → zombie_escape-1.2.1}/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.1
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.1"
@@ -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
@@ -53,6 +53,7 @@ SURVIVOR_CONVERSION_LINE_KEYS = [
53
53
  "stages.stage4.conversion_lines.line2",
54
54
  "stages.stage4.conversion_lines.line3",
55
55
  ]
56
+ SURVIVOR_STAGE_WAITING_CAR_COUNT = 2
56
57
 
57
58
  # --- Flashlight settings ---
58
59
  DEFAULT_FLASHLIGHT_BONUS_SCALE = float(
@@ -83,7 +84,7 @@ ZOMBIE_INTERIOR_SPAWN_RATE = 0.015
83
84
  ZOMBIE_SPAWN_PLAYER_BUFFER = 140
84
85
  ZOMBIE_MODE_CHANGE_INTERVAL_MS = 5000
85
86
  ZOMBIE_SIGHT_RANGE = FOV_RADIUS * 2.0
86
- FAST_ZOMBIE_BASE_SPEED = PLAYER_SPEED * 0.81
87
+ FAST_ZOMBIE_BASE_SPEED = PLAYER_SPEED * 0.77
87
88
  FAST_ZOMBIE_SPEED_JITTER = 0.075
88
89
  ZOMBIE_SEPARATION_DISTANCE = ZOMBIE_RADIUS * 2.2
89
90
 
@@ -170,6 +171,7 @@ __all__ = [
170
171
  "SURVIVOR_OVERLOAD_DAMAGE_RATIO",
171
172
  "SURVIVOR_MESSAGE_DURATION_MS",
172
173
  "SURVIVOR_CONVERSION_LINE_KEYS",
174
+ "SURVIVOR_STAGE_WAITING_CAR_COUNT",
173
175
  "DEFAULT_FLASHLIGHT_BONUS_SCALE",
174
176
  "FLASHLIGHT_WIDTH",
175
177
  "FLASHLIGHT_HEIGHT",
@@ -45,6 +45,7 @@ from ..constants import (
45
45
  SURVIVOR_RADIUS,
46
46
  SURVIVOR_SPAWN_RATE,
47
47
  SURVIVOR_SPEED_PENALTY_PER_PASSENGER,
48
+ SURVIVOR_STAGE_WAITING_CAR_COUNT,
48
49
  ZOMBIE_INTERIOR_SPAWN_RATE,
49
50
  ZOMBIE_RADIUS,
50
51
  ZOMBIE_SEPARATION_DISTANCE,
@@ -68,6 +69,8 @@ from ..entities import (
68
69
  Zombie,
69
70
  )
70
71
 
72
+ LOGICAL_SCREEN_RECT = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
73
+
71
74
  __all__ = [
72
75
  "create_zombie",
73
76
  "rect_for_cell",
@@ -80,8 +83,14 @@ __all__ = [
80
83
  "scatter_positions_on_walkable",
81
84
  "spawn_survivors",
82
85
  "update_survivors",
86
+ "alive_waiting_cars",
87
+ "log_waiting_car_count",
88
+ "nearest_waiting_car",
83
89
  "calculate_car_speed_for_passengers",
84
90
  "apply_passenger_speed_penalty",
91
+ "waiting_car_target_count",
92
+ "spawn_waiting_car",
93
+ "maintain_waiting_car_supply",
85
94
  "add_survivor_message",
86
95
  "random_survivor_conversion_line",
87
96
  "cleanup_survivor_messages",
@@ -91,7 +100,7 @@ __all__ = [
91
100
  "get_shrunk_sprite",
92
101
  "update_footprints",
93
102
  "initialize_game_state",
94
- "setup_player_and_car",
103
+ "setup_player_and_cars",
95
104
  "spawn_initial_zombies",
96
105
  "process_player_input",
97
106
  "update_entities",
@@ -246,6 +255,8 @@ def place_new_car(
246
255
  wall_group: pygame.sprite.Group,
247
256
  player: Player,
248
257
  walkable_cells: list[pygame.Rect],
258
+ *,
259
+ existing_cars: Sequence[Car] | None = None,
249
260
  ) -> Car | None:
250
261
  if not walkable_cells:
251
262
  return None
@@ -268,13 +279,23 @@ def place_new_car(
268
279
  temp_car, nearby_walls, collided=lambda s1, s2: s1.rect.colliderect(s2.rect)
269
280
  )
270
281
  collides_player = temp_rect.colliderect(player.rect.inflate(50, 50))
271
- if not collides_wall and not collides_player:
282
+ car_overlap = False
283
+ if existing_cars:
284
+ car_overlap = any(
285
+ temp_car.rect.colliderect(other.rect)
286
+ for other in existing_cars
287
+ if other and other.alive()
288
+ )
289
+ if not collides_wall and not collides_player and not car_overlap:
272
290
  return temp_car
273
291
  return None
274
292
 
275
293
 
276
294
  def place_fuel_can(
277
- walkable_cells: list[pygame.Rect], player: Player, *, car: Car | None = None
295
+ walkable_cells: list[pygame.Rect],
296
+ player: Player,
297
+ *,
298
+ cars: Sequence[Car] | None = None,
278
299
  ) -> FuelCan | None:
279
300
  """Pick a spawn spot for the fuel can away from the player (and car if given)."""
280
301
  if not walkable_cells:
@@ -290,14 +311,17 @@ def place_fuel_can(
290
311
  < min_player_dist
291
312
  ):
292
313
  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
314
+ if cars:
315
+ too_close = False
316
+ for parked_car in cars:
317
+ if math.hypot(
318
+ cell.centerx - parked_car.rect.centerx,
319
+ cell.centery - parked_car.rect.centery,
320
+ ) < min_car_dist:
321
+ too_close = True
322
+ break
323
+ if too_close:
324
+ continue
301
325
  return FuelCan(cell.centerx, cell.centery)
302
326
 
303
327
  # Fallback: drop near a random walkable cell
@@ -306,7 +330,10 @@ def place_fuel_can(
306
330
 
307
331
 
308
332
  def place_flashlight(
309
- walkable_cells: list[pygame.Rect], player: Player, *, car: Car | None = None
333
+ walkable_cells: list[pygame.Rect],
334
+ player: Player,
335
+ *,
336
+ cars: Sequence[Car] | None = None,
310
337
  ) -> Flashlight | None:
311
338
  """Pick a spawn spot for the flashlight away from the player (and car if given)."""
312
339
  if not walkable_cells:
@@ -322,14 +349,16 @@ def place_flashlight(
322
349
  < min_player_dist
323
350
  ):
324
351
  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
352
+ if cars:
353
+ if any(
354
+ math.hypot(
355
+ cell.centerx - parked.rect.centerx,
356
+ cell.centery - parked.rect.centery,
357
+ )
358
+ < min_car_dist
359
+ for parked in cars
360
+ ):
361
+ continue
333
362
  return Flashlight(cell.centerx, cell.centery)
334
363
 
335
364
  cell = random.choice(walkable_cells)
@@ -340,7 +369,7 @@ def place_flashlights(
340
369
  walkable_cells: list[pygame.Rect],
341
370
  player: Player,
342
371
  *,
343
- car: Car | None = None,
372
+ cars: Sequence[Car] | None = None,
344
373
  count: int = DEFAULT_FLASHLIGHT_SPAWN_COUNT,
345
374
  ) -> list[Flashlight]:
346
375
  """Spawn multiple flashlights using the single-place helper to spread them out."""
@@ -349,7 +378,7 @@ def place_flashlights(
349
378
  max_attempts = max(200, count * 80)
350
379
  while len(placed) < count and attempts < max_attempts:
351
380
  attempts += 1
352
- fl = place_flashlight(walkable_cells, player, car=car)
381
+ fl = place_flashlight(walkable_cells, player, cars=cars)
353
382
  if not fl:
354
383
  break
355
384
  # Avoid clustering too tightly
@@ -367,7 +396,10 @@ def place_flashlights(
367
396
 
368
397
 
369
398
  def place_companion(
370
- walkable_cells: list[pygame.Rect], player: Player, *, car: Car | None = None
399
+ walkable_cells: list[pygame.Rect],
400
+ player: Player,
401
+ *,
402
+ cars: Sequence[Car] | None = None,
371
403
  ) -> Companion | None:
372
404
  """Spawn the stranded buddy somewhere on a walkable tile away from the player and car."""
373
405
  if not walkable_cells:
@@ -383,14 +415,16 @@ def place_companion(
383
415
  < min_player_dist
384
416
  ):
385
417
  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
418
+ if cars:
419
+ if any(
420
+ math.hypot(
421
+ cell.centerx - parked.rect.centerx,
422
+ cell.centery - parked.rect.centery,
423
+ )
424
+ < min_car_dist
425
+ for parked in cars
426
+ ):
427
+ continue
394
428
  return Companion(cell.centerx, cell.centery)
395
429
 
396
430
  cell = random.choice(walkable_cells)
@@ -508,8 +542,12 @@ def update_survivors(game_data: GameData) -> None:
508
542
  other.rect.center = (int(other.x), int(other.y))
509
543
 
510
544
 
511
- def calculate_car_speed_for_passengers(passengers: int) -> float:
512
- penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * passengers
545
+ def calculate_car_speed_for_passengers(
546
+ passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
547
+ ) -> float:
548
+ cap = max(1, capacity)
549
+ load_ratio = max(0.0, passengers / cap)
550
+ penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * load_ratio
513
551
  penalty = min(0.95, max(0.0, penalty))
514
552
  adjusted = CAR_SPEED * (1 - penalty)
515
553
  return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, adjusted)
@@ -522,7 +560,98 @@ def apply_passenger_speed_penalty(game_data: GameData) -> None:
522
560
  if not game_data.stage.survivor_stage:
523
561
  car.speed = CAR_SPEED
524
562
  return
525
- car.speed = calculate_car_speed_for_passengers(game_data.state.survivors_onboard)
563
+ car.speed = calculate_car_speed_for_passengers(
564
+ game_data.state.survivors_onboard,
565
+ capacity=game_data.state.survivor_capacity,
566
+ )
567
+
568
+
569
+ def rect_visible_on_screen(camera: Camera | None, rect: pygame.Rect) -> bool:
570
+ if camera is None:
571
+ return False
572
+ return camera.apply_rect(rect).colliderect(LOGICAL_SCREEN_RECT)
573
+
574
+
575
+ def waiting_car_target_count(stage: Stage) -> int:
576
+ return SURVIVOR_STAGE_WAITING_CAR_COUNT if stage.survivor_stage else 1
577
+
578
+
579
+ def spawn_waiting_car(game_data: GameData) -> Car | None:
580
+ """Attempt to place an additional parked car on the map."""
581
+ player = game_data.player
582
+ if not player:
583
+ return None
584
+ walkable_cells = game_data.areas.walkable_cells
585
+ if not walkable_cells:
586
+ return None
587
+ wall_group = game_data.groups.wall_group
588
+ all_sprites = game_data.groups.all_sprites
589
+ active_car = game_data.car if game_data.car and game_data.car.alive() else None
590
+ waiting = alive_waiting_cars(game_data)
591
+ obstacles: list[Car] = list(waiting)
592
+ if active_car:
593
+ obstacles.append(active_car)
594
+ camera = game_data.camera
595
+ offscreen_attempts = 6
596
+ while offscreen_attempts > 0:
597
+ new_car = place_new_car(
598
+ wall_group,
599
+ player,
600
+ walkable_cells,
601
+ existing_cars=obstacles,
602
+ )
603
+ if not new_car:
604
+ return None
605
+ if rect_visible_on_screen(camera, new_car.rect):
606
+ offscreen_attempts -= 1
607
+ continue
608
+ game_data.waiting_cars.append(new_car)
609
+ all_sprites.add(new_car, layer=1)
610
+ return new_car
611
+ return None
612
+
613
+
614
+ def maintain_waiting_car_supply(
615
+ game_data: GameData, *, minimum: int | None = None
616
+ ) -> None:
617
+ """Ensure a baseline count of parked cars exists."""
618
+ target = 1 if minimum is None else max(0, minimum)
619
+ current = len(alive_waiting_cars(game_data))
620
+ while current < target:
621
+ new_car = spawn_waiting_car(game_data)
622
+ if not new_car:
623
+ break
624
+ current += 1
625
+
626
+
627
+ def alive_waiting_cars(game_data: GameData) -> list[Car]:
628
+ """Return the list of parked cars that still exist, pruning any destroyed sprites."""
629
+ cars = [car for car in game_data.waiting_cars if car.alive()]
630
+ game_data.waiting_cars = cars
631
+ log_waiting_car_count(game_data)
632
+ return cars
633
+
634
+
635
+ def log_waiting_car_count(game_data: GameData, *, force: bool = False) -> None:
636
+ """Print the number of waiting cars when it changes."""
637
+ current = len(game_data.waiting_cars)
638
+ if not force and current == game_data.last_logged_waiting_cars:
639
+ return
640
+ stage_id = getattr(game_data.stage, "id", "unknown")
641
+ game_data.last_logged_waiting_cars = current
642
+
643
+
644
+ def nearest_waiting_car(
645
+ game_data: GameData, origin: tuple[float, float]
646
+ ) -> Car | None:
647
+ """Find the closest waiting car to an origin point."""
648
+ cars = alive_waiting_cars(game_data)
649
+ if not cars:
650
+ return None
651
+ return min(
652
+ cars,
653
+ key=lambda car: math.hypot(car.rect.centerx - origin[0], car.rect.centery - origin[1]),
654
+ )
526
655
 
527
656
 
528
657
  def add_survivor_message(game_data: GameData, text: str) -> None:
@@ -592,7 +721,6 @@ def handle_survivor_zombie_collisions(
592
721
  zombies.sort(key=lambda s: s.rect.centerx)
593
722
  zombie_xs = [z.rect.centerx for z in zombies]
594
723
  camera = game_data.camera
595
- screen_rect = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
596
724
 
597
725
  for survivor in list(survivor_group):
598
726
  if not survivor.alive():
@@ -622,7 +750,7 @@ def handle_survivor_zombie_collisions(
622
750
 
623
751
  if not collided:
624
752
  continue
625
- if not camera.apply_rect(survivor.rect).colliderect(screen_rect):
753
+ if not rect_visible_on_screen(camera, survivor.rect):
626
754
  continue
627
755
  survivor.kill()
628
756
  line = random_survivor_conversion_line()
@@ -749,6 +877,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
749
877
  survivors_onboard=0,
750
878
  survivors_rescued=0,
751
879
  survivor_messages=[],
880
+ survivor_capacity=SURVIVOR_MAX_SAFE_PASSENGERS,
752
881
  )
753
882
 
754
883
  # Create sprite groups
@@ -796,10 +925,13 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
796
925
  )
797
926
 
798
927
 
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."""
928
+ def setup_player_and_cars(
929
+ game_data: GameData,
930
+ layout_data: Mapping[str, list[pygame.Rect]],
931
+ *,
932
+ car_count: int = 1,
933
+ ) -> tuple[Player, list[Car]]:
934
+ """Create the player plus one or more parked cars using blueprint candidates."""
803
935
  all_sprites = game_data.groups.all_sprites
804
936
  walkable_cells: list[pygame.Rect] = layout_data["walkable_cells"]
805
937
 
@@ -813,31 +945,38 @@ def setup_player_and_car(
813
945
  player_pos = pick_center(layout_data["player_cells"] or walkable_cells)
814
946
  player = Player(*player_pos)
815
947
 
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
948
+ car_candidates = list(layout_data["car_cells"] or walkable_cells)
949
+ waiting_cars: list[Car] = []
950
+
951
+ def _pick_car_position() -> tuple[int, int]:
952
+ """Favor distant cells for the first car, otherwise fall back to random picks."""
953
+ if not car_candidates:
954
+ return (player_pos[0] + 200, player_pos[1])
955
+ random.shuffle(car_candidates)
956
+ for candidate in car_candidates:
957
+ if (
958
+ math.hypot(
959
+ candidate.centerx - player_pos[0],
960
+ candidate.centery - player_pos[1],
961
+ )
962
+ >= 400
963
+ ):
964
+ car_candidates.remove(candidate)
965
+ return candidate.center
966
+ # No far-enough cells found; pick the first available
967
+ choice = car_candidates.pop()
968
+ return choice.center
969
+
970
+ for idx in range(max(1, car_count)):
971
+ car_pos = _pick_car_position()
972
+ car = Car(*car_pos)
973
+ waiting_cars.append(car)
974
+ all_sprites.add(car, layer=1)
975
+ if not car_candidates:
828
976
  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
977
 
834
- car = Car(*car_pos)
835
-
836
- # Add to sprite groups
837
978
  all_sprites.add(player, layer=2)
838
- all_sprites.add(car, layer=1)
839
-
840
- return player, car
979
+ return player, waiting_cars
841
980
 
842
981
 
843
982
  def spawn_initial_zombies(
@@ -878,7 +1017,7 @@ def spawn_initial_zombies(
878
1017
 
879
1018
 
880
1019
  def process_player_input(
881
- keys: Sequence[bool], player: Player, car: Car
1020
+ keys: Sequence[bool], player: Player, car: Car | None
882
1021
  ) -> tuple[float, float, float, float]:
883
1022
  """Process keyboard input and return movement deltas."""
884
1023
  dx_input, dy_input = 0, 0
@@ -893,7 +1032,7 @@ def process_player_input(
893
1032
 
894
1033
  player_dx, player_dy, car_dx, car_dy = 0, 0, 0, 0
895
1034
 
896
- if player.in_car and car.alive():
1035
+ if player.in_car and car and car.alive():
897
1036
  target_speed = getattr(car, "speed", CAR_SPEED)
898
1037
  move_len = math.hypot(dx_input, dy_input)
899
1038
  if move_len > 0:
@@ -925,32 +1064,35 @@ def update_entities(
925
1064
  player = game_data.player
926
1065
  assert player is not None
927
1066
  car = game_data.car
928
- assert car is not None
929
1067
  companion = game_data.companion
930
1068
  wall_group = game_data.groups.wall_group
931
1069
  all_sprites = game_data.groups.all_sprites
932
1070
  zombie_group = game_data.groups.zombie_group
933
1071
  camera = game_data.camera
934
1072
  stage = game_data.stage
1073
+ active_car = car if car and car.alive() else None
935
1074
 
936
1075
  # 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
1076
+ if player.in_car and active_car:
1077
+ active_car.move(car_dx, car_dy, wall_group)
1078
+ player.rect.center = active_car.rect.center
1079
+ player.x, player.y = active_car.x, active_car.y
941
1080
  elif not player.in_car:
942
1081
  # Ensure player is in all_sprites if not in car
943
1082
  if player not in all_sprites:
944
1083
  all_sprites.add(player, layer=2)
945
1084
  player.move(player_dx, player_dy, wall_group)
1085
+ else:
1086
+ # Player flagged as in-car but car is gone; drop them back to foot control
1087
+ player.in_car = False
946
1088
 
947
1089
  # Update camera
948
- target_for_camera = car if player.in_car and car.alive() else player
1090
+ target_for_camera = active_car if player.in_car and active_car else player
949
1091
  camera.update(target_for_camera)
950
1092
 
951
1093
  # Update companion (Stage 3 follow logic)
952
1094
  if companion and companion.alive() and not companion.rescued:
953
- follow_target = car if player.in_car and car.alive() else player
1095
+ follow_target = active_car if player.in_car and active_car else player
954
1096
  companion.update_follow(follow_target.rect.center, wall_group)
955
1097
  if companion not in all_sprites:
956
1098
  all_sprites.add(companion, layer=2)
@@ -971,24 +1113,23 @@ def update_entities(
971
1113
 
972
1114
  # Update zombies
973
1115
  target_center = (
974
- car.rect.center if player.in_car and car.alive() else player.rect.center
1116
+ active_car.rect.center if player.in_car and active_car else player.rect.center
975
1117
  )
976
1118
  companion_on_screen = False
977
- screen_rect = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
978
1119
  if (
979
1120
  game_data.stage.requires_companion
980
1121
  and companion
981
1122
  and companion.alive()
982
1123
  and not companion.rescued
983
1124
  ):
984
- companion_on_screen = camera.apply_rect(companion.rect).colliderect(screen_rect)
1125
+ companion_on_screen = rect_visible_on_screen(camera, companion.rect)
985
1126
 
986
1127
  survivors_on_screen: list[Survivor] = []
987
1128
  if stage.survivor_stage:
988
1129
  survivor_group = game_data.groups.survivor_group
989
1130
  for survivor in survivor_group:
990
1131
  if getattr(survivor, "alive", lambda: False)():
991
- if camera.apply_rect(survivor.rect).colliderect(screen_rect):
1132
+ if rect_visible_on_screen(camera, survivor.rect):
992
1133
  survivors_on_screen.append(survivor)
993
1134
 
994
1135
  zombies_sorted: list[Zombie] = sorted(list(zombie_group), key=lambda z: z.x)
@@ -1024,7 +1165,7 @@ def update_entities(
1024
1165
  target = companion.rect.center
1025
1166
 
1026
1167
  if stage.survivor_stage:
1027
- zombie_on_screen = camera.apply_rect(zombie.rect).colliderect(screen_rect)
1168
+ zombie_on_screen = rect_visible_on_screen(camera, zombie.rect)
1028
1169
  if zombie_on_screen:
1029
1170
  candidate_positions: list[tuple[int, int]] = []
1030
1171
  for survivor in survivors_on_screen:
@@ -1050,10 +1191,8 @@ def check_interactions(
1050
1191
  player = game_data.player
1051
1192
  assert player is not None
1052
1193
  car = game_data.car
1053
- assert car is not None
1054
1194
  companion = game_data.companion
1055
1195
  zombie_group = game_data.groups.zombie_group
1056
- wall_group = game_data.groups.wall_group
1057
1196
  all_sprites = game_data.groups.all_sprites
1058
1197
  survivor_group = game_data.groups.survivor_group
1059
1198
  state = game_data.state
@@ -1063,6 +1202,9 @@ def check_interactions(
1063
1202
  flashlights = game_data.flashlights or []
1064
1203
  camera = game_data.camera
1065
1204
  stage = game_data.stage
1205
+ maintain_waiting_car_supply(game_data)
1206
+ active_car = car if car and car.alive() else None
1207
+ waiting_cars = game_data.waiting_cars
1066
1208
 
1067
1209
  # Fuel pickup
1068
1210
  if fuel and fuel.alive() and not state.has_fuel and not player.in_car:
@@ -1104,8 +1246,7 @@ def check_interactions(
1104
1246
  )
1105
1247
  if companion_active:
1106
1248
  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)
1249
+ companion_on_screen = rect_visible_on_screen(camera, companion.rect)
1109
1250
 
1110
1251
  # Companion interactions (Stage 3)
1111
1252
  if companion_active and stage.requires_companion:
@@ -1113,10 +1254,10 @@ def check_interactions(
1113
1254
  if not player.in_car:
1114
1255
  if pygame.sprite.collide_circle(companion, player):
1115
1256
  companion.set_following()
1116
- elif player.in_car and car.alive():
1257
+ elif player.in_car and active_car:
1117
1258
  g = pygame.sprite.Group()
1118
1259
  g.add(companion)
1119
- if pygame.sprite.spritecollide(get_shrunk_sprite(car, 0.8), g, False):
1260
+ if pygame.sprite.spritecollide(get_shrunk_sprite(active_car, 0.8), g, False):
1120
1261
  state.companion_rescued = True
1121
1262
  companion.mark_rescued()
1122
1263
  companion.kill()
@@ -1140,13 +1281,18 @@ def check_interactions(
1140
1281
  companion.teleport((LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2))
1141
1282
  companion.following = False
1142
1283
 
1143
- shrunk_car = get_shrunk_sprite(car, 0.8) if car else None
1284
+ player_group = pygame.sprite.Group()
1285
+ player_group.add(player)
1144
1286
 
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):
1287
+ # Player entering an active car already under control
1288
+ if (
1289
+ not player.in_car
1290
+ and active_car
1291
+ and active_car.alive()
1292
+ and active_car.health > 0
1293
+ ):
1294
+ shrunk_active = get_shrunk_sprite(active_car, 0.8)
1295
+ if pygame.sprite.spritecollide(shrunk_active, player_group, False):
1150
1296
  if state.has_fuel:
1151
1297
  player.in_car = True
1152
1298
  all_sprites.remove(player)
@@ -1156,19 +1302,72 @@ def check_interactions(
1156
1302
  else:
1157
1303
  now_ms = state.elapsed_play_ms
1158
1304
  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
1305
  state.hint_target_type = "fuel"
1161
1306
 
1307
+ # Claim a waiting/parked car when the player finally reaches it
1308
+ if not player.in_car and not active_car and waiting_cars:
1309
+ claimed_car: Car | None = None
1310
+ for parked_car in waiting_cars:
1311
+ shrunk_waiting = get_shrunk_sprite(parked_car, 0.8)
1312
+ if pygame.sprite.spritecollide(shrunk_waiting, player_group, False):
1313
+ claimed_car = parked_car
1314
+ break
1315
+ if claimed_car:
1316
+ if state.has_fuel:
1317
+ try:
1318
+ game_data.waiting_cars.remove(claimed_car)
1319
+ except ValueError:
1320
+ pass
1321
+ game_data.car = claimed_car
1322
+ active_car = claimed_car
1323
+ player.in_car = True
1324
+ all_sprites.remove(player)
1325
+ state.hint_expires_at = 0
1326
+ state.hint_target_type = None
1327
+ apply_passenger_speed_penalty(game_data)
1328
+ maintain_waiting_car_supply(game_data)
1329
+ print("Player claimed a waiting car!")
1330
+ else:
1331
+ now_ms = state.elapsed_play_ms
1332
+ state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
1333
+ state.hint_target_type = "fuel"
1334
+
1335
+ shrunk_car = get_shrunk_sprite(active_car, 0.8) if active_car else None
1336
+
1337
+ # Bonus: collide a parked car while driving to repair/extend capabilities
1338
+ if player.in_car and active_car and shrunk_car and waiting_cars:
1339
+ waiting_group = pygame.sprite.Group(waiting_cars)
1340
+ collided_waiters = pygame.sprite.spritecollide(
1341
+ shrunk_car, waiting_group, False, pygame.sprite.collide_rect
1342
+ )
1343
+ if collided_waiters:
1344
+ removed_any = False
1345
+ for parked in collided_waiters:
1346
+ if not parked.alive():
1347
+ continue
1348
+ parked.kill()
1349
+ try:
1350
+ game_data.waiting_cars.remove(parked)
1351
+ except ValueError:
1352
+ pass
1353
+ active_car.health = active_car.max_health
1354
+ active_car.update_color()
1355
+ removed_any = True
1356
+ if stage.survivor_stage:
1357
+ state.survivor_capacity += SURVIVOR_MAX_SAFE_PASSENGERS
1358
+ if removed_any:
1359
+ maintain_waiting_car_supply(game_data)
1360
+
1162
1361
  # Car hitting zombies
1163
- if player.in_car and car.alive() and car.health > 0:
1362
+ if player.in_car and active_car and active_car.health > 0 and shrunk_car:
1164
1363
  zombies_hit = pygame.sprite.spritecollide(shrunk_car, zombie_group, True)
1165
1364
  if zombies_hit:
1166
- car.take_damage(CAR_ZOMBIE_DAMAGE * len(zombies_hit))
1365
+ active_car.take_damage(CAR_ZOMBIE_DAMAGE * len(zombies_hit))
1167
1366
 
1168
1367
  if (
1169
1368
  stage.survivor_stage
1170
1369
  and player.in_car
1171
- and car.alive()
1370
+ and active_car
1172
1371
  and shrunk_car
1173
1372
  and survivor_group
1174
1373
  ):
@@ -1178,18 +1377,20 @@ def check_interactions(
1178
1377
  if boarded:
1179
1378
  state.survivors_onboard += len(boarded)
1180
1379
  apply_passenger_speed_penalty(game_data)
1181
- if state.survivors_onboard > SURVIVOR_MAX_SAFE_PASSENGERS:
1380
+ capacity_limit = state.survivor_capacity
1381
+ if state.survivors_onboard > capacity_limit:
1182
1382
  overload_damage = max(
1183
- 1, int(game_data.car.max_health * SURVIVOR_OVERLOAD_DAMAGE_RATIO)
1383
+ 1,
1384
+ int(active_car.max_health * SURVIVOR_OVERLOAD_DAMAGE_RATIO),
1184
1385
  )
1185
1386
  add_survivor_message(game_data, _("survivors.too_many_aboard"))
1186
- game_data.car.take_damage(overload_damage)
1387
+ active_car.take_damage(overload_damage)
1187
1388
 
1188
1389
  if stage.survivor_stage:
1189
1390
  handle_survivor_zombie_collisions(game_data, config)
1190
1391
 
1191
1392
  # Handle car destruction
1192
- if car.alive() and car.health <= 0:
1393
+ if car and car.alive() and car.health <= 0:
1193
1394
  car_destroyed_pos = car.rect.center
1194
1395
  car.kill()
1195
1396
  if stage.survivor_stage:
@@ -1202,21 +1403,14 @@ def check_interactions(
1202
1403
  all_sprites.add(player, layer=2)
1203
1404
  print("Car destroyed! Player ejected.")
1204
1405
 
1406
+ # Clear active car and let the player hunt for another waiting car.
1407
+ game_data.car = None
1408
+ state.survivor_capacity = SURVIVOR_MAX_SAFE_PASSENGERS
1409
+ apply_passenger_speed_penalty(game_data)
1410
+
1205
1411
  # Bring back the rescued companion near the player after losing the car
1206
1412
  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!")
1413
+ maintain_waiting_car_supply(game_data)
1220
1414
 
1221
1415
  # Player getting caught by zombies
1222
1416
  if not player.in_car and player in all_sprites:
@@ -1230,7 +1424,7 @@ def check_interactions(
1230
1424
  state.game_over_message = _("game_over.scream")
1231
1425
 
1232
1426
  # Player escaping the level
1233
- if player.in_car and car.alive() and state.has_fuel:
1427
+ if player.in_car and car and car.alive() and state.has_fuel:
1234
1428
  companion_ready = not stage.requires_companion or state.companion_rescued
1235
1429
  if companion_ready and any(
1236
1430
  outside.collidepoint(car.rect.center) for outside in outside_rects
@@ -1244,5 +1438,5 @@ def check_interactions(
1244
1438
 
1245
1439
  # Return fog of view target
1246
1440
  if not state.game_over and not state.game_won:
1247
- return car if player.in_car and car.alive() else player
1441
+ return car if player.in_car and car and car.alive() else player
1248
1442
  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,7 +75,9 @@ 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
80
+ last_logged_waiting_cars: int | None = None
78
81
 
79
82
 
80
83
  @dataclass(frozen=True)
@@ -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
  )
@@ -9,6 +9,7 @@ from ..colors import LIGHT_GRAY, RED, WHITE, YELLOW
9
9
  from ..constants import (
10
10
  CAR_HINT_DELAY_MS_DEFAULT,
11
11
  DEFAULT_FLASHLIGHT_SPAWN_COUNT,
12
+ SURVIVOR_STAGE_WAITING_CAR_COUNT,
12
13
  )
13
14
  from ..gameplay import logic
14
15
  from ..localization import translate as _
@@ -41,9 +42,19 @@ def gameplay_screen(
41
42
  last_fov_target = None
42
43
 
43
44
  layout_data = logic.generate_level_from_blueprint(game_data, config)
44
- player, car = logic.setup_player_and_car(game_data, layout_data)
45
+ initial_waiting = (
46
+ SURVIVOR_STAGE_WAITING_CAR_COUNT if stage.survivor_stage else 1
47
+ )
48
+ player, waiting_cars = logic.setup_player_and_cars(
49
+ game_data, layout_data, car_count=initial_waiting
50
+ )
45
51
  game_data.player = player
46
- game_data.car = car
52
+ game_data.waiting_cars = waiting_cars
53
+ game_data.car = None
54
+ # Only top up if initial placement spawned fewer than the intended baseline (shouldn't happen)
55
+ logic.maintain_waiting_car_supply(
56
+ game_data, minimum=logic.waiting_car_target_count(stage)
57
+ )
47
58
  logic.apply_passenger_speed_penalty(game_data)
48
59
 
49
60
  if stage.survivor_stage:
@@ -59,7 +70,7 @@ def gameplay_screen(
59
70
 
60
71
  if stage.requires_fuel:
61
72
  fuel_can = logic.place_fuel_can(
62
- layout_data["walkable_cells"], player, car=car
73
+ layout_data["walkable_cells"], player, cars=game_data.waiting_cars
63
74
  )
64
75
  if fuel_can:
65
76
  game_data.fuel = fuel_can
@@ -68,7 +79,7 @@ def gameplay_screen(
68
79
  flashlights = logic.place_flashlights(
69
80
  layout_data["walkable_cells"],
70
81
  player,
71
- car=car,
82
+ cars=game_data.waiting_cars,
72
83
  count=max(1, flashlight_count),
73
84
  )
74
85
  game_data.flashlights = flashlights
@@ -76,7 +87,7 @@ def gameplay_screen(
76
87
 
77
88
  if stage.requires_companion:
78
89
  companion = logic.place_companion(
79
- layout_data["walkable_cells"], player, car=car
90
+ layout_data["walkable_cells"], player, cars=game_data.waiting_cars
80
91
  )
81
92
  if companion:
82
93
  game_data.companion = companion
@@ -222,10 +233,13 @@ def gameplay_screen(
222
233
  hint_expires_at = game_data.state.hint_expires_at
223
234
  hint_target_type = game_data.state.hint_target_type
224
235
 
236
+ active_car = game_data.car if game_data.car and game_data.car.alive() else None
225
237
  if hint_enabled:
226
238
  if not has_fuel and game_data.fuel and game_data.fuel.alive():
227
239
  target_type = "fuel"
228
- elif not player.in_car and game_data.car.alive():
240
+ elif not player.in_car and (
241
+ active_car or logic.alive_waiting_cars(game_data)
242
+ ):
229
243
  target_type = "car"
230
244
  else:
231
245
  target_type = None
@@ -246,8 +260,15 @@ def gameplay_screen(
246
260
  ):
247
261
  if target_type == "fuel" and game_data.fuel and game_data.fuel.alive():
248
262
  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
263
+ elif target_type == "car":
264
+ if active_car:
265
+ hint_target = active_car.rect.center
266
+ else:
267
+ waiting_target = logic.nearest_waiting_car(
268
+ game_data, (player.x, player.y)
269
+ )
270
+ if waiting_target:
271
+ hint_target = waiting_target.rect.center
251
272
 
252
273
  draw(
253
274
  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