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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/__main__.py +7 -0
  3. zombie_escape/colors.py +22 -14
  4. zombie_escape/entities.py +756 -147
  5. zombie_escape/entities_constants.py +35 -14
  6. zombie_escape/export_images.py +296 -0
  7. zombie_escape/gameplay/__init__.py +2 -1
  8. zombie_escape/gameplay/constants.py +6 -0
  9. zombie_escape/gameplay/footprints.py +4 -0
  10. zombie_escape/gameplay/interactions.py +19 -7
  11. zombie_escape/gameplay/layout.py +103 -34
  12. zombie_escape/gameplay/movement.py +85 -5
  13. zombie_escape/gameplay/spawn.py +139 -90
  14. zombie_escape/gameplay/state.py +18 -9
  15. zombie_escape/gameplay/survivors.py +13 -2
  16. zombie_escape/gameplay/utils.py +40 -21
  17. zombie_escape/level_blueprints.py +256 -19
  18. zombie_escape/locales/ui.en.json +12 -2
  19. zombie_escape/locales/ui.ja.json +12 -2
  20. zombie_escape/models.py +14 -7
  21. zombie_escape/render.py +149 -37
  22. zombie_escape/render_assets.py +419 -124
  23. zombie_escape/render_constants.py +27 -0
  24. zombie_escape/screens/game_over.py +14 -3
  25. zombie_escape/screens/gameplay.py +72 -14
  26. zombie_escape/screens/title.py +18 -7
  27. zombie_escape/stage_constants.py +51 -15
  28. zombie_escape/zombie_escape.py +24 -1
  29. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/METADATA +41 -15
  30. zombie_escape-1.13.1.dist-info/RECORD +49 -0
  31. zombie_escape-1.12.0.dist-info/RECORD +0 -47
  32. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/WHEEL +0 -0
  33. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/entry_points.txt +0 -0
  34. {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -35,6 +35,9 @@ from ..render_constants import (
35
35
  )
36
36
  from ..rng import get_rng
37
37
  from .constants import (
38
+ FALLING_ZOMBIE_DUST_DURATION_MS,
39
+ FALLING_ZOMBIE_DURATION_MS,
40
+ FALLING_ZOMBIE_PRE_FX_MS,
38
41
  MAX_ZOMBIES,
39
42
  ZOMBIE_SPAWN_PLAYER_BUFFER,
40
43
  ZOMBIE_TRACKER_AGING_DURATION_FRAMES,
@@ -68,6 +71,13 @@ __all__ = [
68
71
  ]
69
72
 
70
73
 
74
+ def _cell_center(cell: tuple[int, int], cell_size: int) -> tuple[int, int]:
75
+ return (
76
+ int((cell[0] * cell_size) + (cell_size / 2)),
77
+ int((cell[1] * cell_size) + (cell_size / 2)),
78
+ )
79
+
80
+
71
81
  def _car_appearance_for_stage(stage: Stage | None) -> str:
72
82
  return "disabled" if stage and stage.endurance_stage else "default"
73
83
 
@@ -75,22 +85,22 @@ def _car_appearance_for_stage(stage: Stage | None) -> str:
75
85
  def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
76
86
  normal_ratio = 1.0
77
87
  tracker_ratio = 0.0
78
- wall_follower_ratio = 0.0
88
+ wall_hugging_ratio = 0.0
79
89
  if stage is not None:
80
90
  normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
81
91
  tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
82
- wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
83
- if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
92
+ wall_hugging_ratio = max(0.0, min(1.0, stage.zombie_wall_hugging_ratio))
93
+ if normal_ratio + tracker_ratio + wall_hugging_ratio <= 0:
84
94
  normal_ratio = 1.0
85
95
  tracker_ratio = 0.0
86
- wall_follower_ratio = 0.0
96
+ wall_hugging_ratio = 0.0
87
97
  if (
88
98
  normal_ratio == 1.0
89
- and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
90
- and tracker_ratio + wall_follower_ratio <= 1.0
99
+ and (tracker_ratio > 0.0 or wall_hugging_ratio > 0.0)
100
+ and tracker_ratio + wall_hugging_ratio <= 1.0
91
101
  ):
92
- normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
93
- total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
102
+ normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_hugging_ratio)
103
+ total_ratio = normal_ratio + tracker_ratio + wall_hugging_ratio
94
104
  if total_ratio <= 0:
95
105
  return False, False
96
106
  pick = RNG.random() * total_ratio
@@ -203,7 +213,7 @@ def _schedule_falling_zombie(
203
213
  if len(zombie_group) + len(state.falling_zombies) >= MAX_ZOMBIES:
204
214
  return "blocked"
205
215
  min_distance = game_data.stage.tile_size * 0.5
206
- tracker, wall_follower = _pick_zombie_variant(game_data.stage)
216
+ tracker, wall_hugging = _pick_zombie_variant(game_data.stage)
207
217
 
208
218
  def _candidate_clear(pos: tuple[int, int]) -> bool:
209
219
  candidate = _create_zombie(
@@ -211,7 +221,7 @@ def _schedule_falling_zombie(
211
221
  start_pos=pos,
212
222
  stage=game_data.stage,
213
223
  tracker=tracker,
214
- wall_follower=wall_follower,
224
+ wall_hugging=wall_hugging,
215
225
  )
216
226
  return _is_spawn_position_clear(game_data, candidate)
217
227
 
@@ -224,17 +234,17 @@ def _schedule_falling_zombie(
224
234
  if allow_carry:
225
235
  state.falling_spawn_carry += 1
226
236
  return "no_position"
227
- start_offset = game_data.stage.tile_size * 0.7
228
- start_pos = (int(spawn_pos[0]), int(spawn_pos[1] - start_offset))
237
+ # start_offset removed; animation handles "falling" via scaling now.
238
+ start_pos = (int(spawn_pos[0]), int(spawn_pos[1]))
229
239
  fall = FallingZombie(
230
240
  start_pos=start_pos,
231
241
  target_pos=(int(spawn_pos[0]), int(spawn_pos[1])),
232
242
  started_at_ms=pygame.time.get_ticks(),
233
- pre_fx_ms=350,
234
- fall_duration_ms=450,
235
- dust_duration_ms=220,
243
+ pre_fx_ms=FALLING_ZOMBIE_PRE_FX_MS,
244
+ fall_duration_ms=FALLING_ZOMBIE_DURATION_MS,
245
+ dust_duration_ms=FALLING_ZOMBIE_DUST_DURATION_MS,
236
246
  tracker=tracker,
237
- wall_follower=wall_follower,
247
+ wall_hugging=wall_hugging,
238
248
  )
239
249
  state.falling_zombies.append(fall)
240
250
  return "scheduled"
@@ -247,7 +257,7 @@ def _create_zombie(
247
257
  hint_pos: tuple[float, float] | None = None,
248
258
  stage: Stage | None = None,
249
259
  tracker: bool | None = None,
250
- wall_follower: bool | None = None,
260
+ wall_hugging: bool | None = None,
251
261
  ) -> Zombie:
252
262
  """Factory to create zombies with optional fast variants."""
253
263
  fast_conf = config.get("fast_zombies", {})
@@ -264,14 +274,14 @@ def _create_zombie(
264
274
  )
265
275
  else:
266
276
  aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
267
- if tracker is None or wall_follower is None:
268
- picked_tracker, picked_wall_follower = _pick_zombie_variant(stage)
277
+ if tracker is None or wall_hugging is None:
278
+ picked_tracker, picked_wall_hugging = _pick_zombie_variant(stage)
269
279
  if tracker is None:
270
280
  tracker = picked_tracker
271
- if wall_follower is None:
272
- wall_follower = picked_wall_follower
281
+ if wall_hugging is None:
282
+ wall_hugging = picked_wall_hugging
273
283
  if tracker:
274
- wall_follower = False
284
+ wall_hugging = False
275
285
  if tracker:
276
286
  ratio = (
277
287
  ZOMBIE_TRACKER_AGING_DURATION_FRAMES / ZOMBIE_AGING_DURATION_FRAMES
@@ -305,13 +315,14 @@ def _create_zombie(
305
315
  y=float(start_pos[1]),
306
316
  speed=base_speed,
307
317
  tracker=tracker,
308
- wall_follower=wall_follower,
318
+ wall_hugging=wall_hugging,
309
319
  aging_duration_frames=aging_duration_frames,
310
320
  )
311
321
 
312
322
 
313
323
  def place_fuel_can(
314
- walkable_cells: list[pygame.Rect],
324
+ walkable_cells: list[tuple[int, int]],
325
+ cell_size: int,
315
326
  player: Player,
316
327
  *,
317
328
  cars: Sequence[Car] | None = None,
@@ -329,30 +340,33 @@ def place_fuel_can(
329
340
 
330
341
  for _ in range(200):
331
342
  cell = RNG.choice(walkable_cells)
332
- if reserved_centers and cell.center in reserved_centers:
343
+ center = _cell_center(cell, cell_size)
344
+ if reserved_centers and center in reserved_centers:
333
345
  continue
334
- dx = cell.centerx - player.x
335
- dy = cell.centery - player.y
346
+ dx = center[0] - player.x
347
+ dy = center[1] - player.y
336
348
  if dx * dx + dy * dy < min_player_dist_sq:
337
349
  continue
338
350
  if cars:
339
351
  too_close = False
340
352
  for parked_car in cars:
341
- dx = cell.centerx - parked_car.rect.centerx
342
- dy = cell.centery - parked_car.rect.centery
353
+ dx = center[0] - parked_car.rect.centerx
354
+ dy = center[1] - parked_car.rect.centery
343
355
  if dx * dx + dy * dy < min_car_dist_sq:
344
356
  too_close = True
345
357
  break
346
358
  if too_close:
347
359
  continue
348
- return FuelCan(cell.centerx, cell.centery)
360
+ return FuelCan(center[0], center[1])
349
361
 
350
362
  cell = RNG.choice(walkable_cells)
351
- return FuelCan(cell.centerx, cell.centery)
363
+ center = _cell_center(cell, cell_size)
364
+ return FuelCan(center[0], center[1])
352
365
 
353
366
 
354
367
  def _place_flashlight(
355
- walkable_cells: list[pygame.Rect],
368
+ walkable_cells: list[tuple[int, int]],
369
+ cell_size: int,
356
370
  player: Player,
357
371
  *,
358
372
  cars: Sequence[Car] | None = None,
@@ -369,28 +383,31 @@ def _place_flashlight(
369
383
 
370
384
  for _ in range(200):
371
385
  cell = RNG.choice(walkable_cells)
372
- if reserved_centers and cell.center in reserved_centers:
386
+ center = _cell_center(cell, cell_size)
387
+ if reserved_centers and center in reserved_centers:
373
388
  continue
374
- dx = cell.centerx - player.x
375
- dy = cell.centery - player.y
389
+ dx = center[0] - player.x
390
+ dy = center[1] - player.y
376
391
  if dx * dx + dy * dy < min_player_dist_sq:
377
392
  continue
378
393
  if cars:
379
394
  if any(
380
- (cell.centerx - parked.rect.centerx) ** 2
381
- + (cell.centery - parked.rect.centery) ** 2
395
+ (center[0] - parked.rect.centerx) ** 2
396
+ + (center[1] - parked.rect.centery) ** 2
382
397
  < min_car_dist_sq
383
398
  for parked in cars
384
399
  ):
385
400
  continue
386
- return Flashlight(cell.centerx, cell.centery)
401
+ return Flashlight(center[0], center[1])
387
402
 
388
403
  cell = RNG.choice(walkable_cells)
389
- return Flashlight(cell.centerx, cell.centery)
404
+ center = _cell_center(cell, cell_size)
405
+ return Flashlight(center[0], center[1])
390
406
 
391
407
 
392
408
  def place_flashlights(
393
- walkable_cells: list[pygame.Rect],
409
+ walkable_cells: list[tuple[int, int]],
410
+ cell_size: int,
394
411
  player: Player,
395
412
  *,
396
413
  cars: Sequence[Car] | None = None,
@@ -404,7 +421,11 @@ def place_flashlights(
404
421
  while len(placed) < count and attempts < max_attempts:
405
422
  attempts += 1
406
423
  fl = _place_flashlight(
407
- walkable_cells, player, cars=cars, reserved_centers=reserved_centers
424
+ walkable_cells,
425
+ cell_size,
426
+ player,
427
+ cars=cars,
428
+ reserved_centers=reserved_centers,
408
429
  )
409
430
  if not fl:
410
431
  break
@@ -421,7 +442,8 @@ def place_flashlights(
421
442
 
422
443
 
423
444
  def _place_shoes(
424
- walkable_cells: list[pygame.Rect],
445
+ walkable_cells: list[tuple[int, int]],
446
+ cell_size: int,
425
447
  player: Player,
426
448
  *,
427
449
  cars: Sequence[Car] | None = None,
@@ -438,28 +460,31 @@ def _place_shoes(
438
460
 
439
461
  for _ in range(200):
440
462
  cell = RNG.choice(walkable_cells)
441
- if reserved_centers and cell.center in reserved_centers:
463
+ center = _cell_center(cell, cell_size)
464
+ if reserved_centers and center in reserved_centers:
442
465
  continue
443
- dx = cell.centerx - player.x
444
- dy = cell.centery - player.y
466
+ dx = center[0] - player.x
467
+ dy = center[1] - player.y
445
468
  if dx * dx + dy * dy < min_player_dist_sq:
446
469
  continue
447
470
  if cars:
448
471
  if any(
449
- (cell.centerx - parked.rect.centerx) ** 2
450
- + (cell.centery - parked.rect.centery) ** 2
472
+ (center[0] - parked.rect.centerx) ** 2
473
+ + (center[1] - parked.rect.centery) ** 2
451
474
  < min_car_dist_sq
452
475
  for parked in cars
453
476
  ):
454
477
  continue
455
- return Shoes(cell.centerx, cell.centery)
478
+ return Shoes(center[0], center[1])
456
479
 
457
480
  cell = RNG.choice(walkable_cells)
458
- return Shoes(cell.centerx, cell.centery)
481
+ center = _cell_center(cell, cell_size)
482
+ return Shoes(center[0], center[1])
459
483
 
460
484
 
461
485
  def place_shoes(
462
- walkable_cells: list[pygame.Rect],
486
+ walkable_cells: list[tuple[int, int]],
487
+ cell_size: int,
463
488
  player: Player,
464
489
  *,
465
490
  cars: Sequence[Car] | None = None,
@@ -473,7 +498,11 @@ def place_shoes(
473
498
  while len(placed) < count and attempts < max_attempts:
474
499
  attempts += 1
475
500
  shoes = _place_shoes(
476
- walkable_cells, player, cars=cars, reserved_centers=reserved_centers
501
+ walkable_cells,
502
+ cell_size,
503
+ player,
504
+ cars=cars,
505
+ reserved_centers=reserved_centers,
477
506
  )
478
507
  if not shoes:
479
508
  break
@@ -489,7 +518,8 @@ def place_shoes(
489
518
 
490
519
 
491
520
  def place_buddies(
492
- walkable_cells: list[pygame.Rect],
521
+ walkable_cells: list[tuple[int, int]],
522
+ cell_size: int,
493
523
  player: Player,
494
524
  *,
495
525
  cars: Sequence[Car] | None = None,
@@ -501,6 +531,7 @@ def place_buddies(
501
531
  min_player_dist = 240
502
532
  positions = find_interior_spawn_positions(
503
533
  walkable_cells,
534
+ cell_size,
504
535
  1.0,
505
536
  player=player,
506
537
  min_player_dist=min_player_dist,
@@ -510,7 +541,10 @@ def place_buddies(
510
541
  placed.append(Survivor(pos[0], pos[1], is_buddy=True))
511
542
  remaining = count - len(placed)
512
543
  for _ in range(max(0, remaining)):
513
- spawn_pos = find_nearby_offscreen_spawn_position(walkable_cells)
544
+ spawn_pos = find_nearby_offscreen_spawn_position(
545
+ walkable_cells,
546
+ cell_size,
547
+ )
514
548
  placed.append(Survivor(spawn_pos[0], spawn_pos[1], is_buddy=True))
515
549
  return placed
516
550
 
@@ -518,7 +552,8 @@ def place_buddies(
518
552
  def place_new_car(
519
553
  wall_group: pygame.sprite.Group,
520
554
  player: Player,
521
- walkable_cells: list[pygame.Rect],
555
+ walkable_cells: list[tuple[int, int]],
556
+ cell_size: int,
522
557
  *,
523
558
  existing_cars: Sequence[Car] | None = None,
524
559
  appearance: str = "default",
@@ -529,7 +564,7 @@ def place_new_car(
529
564
  max_attempts = 150
530
565
  for _ in range(max_attempts):
531
566
  cell = RNG.choice(walkable_cells)
532
- c_x, c_y = cell.center
567
+ c_x, c_y = _cell_center(cell, cell_size)
533
568
  temp_car = Car(c_x, c_y, appearance=appearance)
534
569
  temp_rect = temp_car.rect.inflate(30, 30)
535
570
  nearby_walls = pygame.sprite.Group()
@@ -555,7 +590,7 @@ def place_new_car(
555
590
 
556
591
 
557
592
  def spawn_survivors(
558
- game_data: GameData, layout_data: Mapping[str, list[pygame.Rect]]
593
+ game_data: GameData, layout_data: Mapping[str, list[tuple[int, int]]]
559
594
  ) -> list[Survivor]:
560
595
  """Populate rescue-stage survivors and buddy-stage buddies."""
561
596
  survivors: list[Survivor] = []
@@ -566,10 +601,12 @@ def spawn_survivors(
566
601
  wall_group = game_data.groups.wall_group
567
602
  survivor_group = game_data.groups.survivor_group
568
603
  all_sprites = game_data.groups.all_sprites
604
+ cell_size = game_data.cell_size
569
605
 
570
606
  if game_data.stage.rescue_stage:
571
607
  positions = find_interior_spawn_positions(
572
608
  walkable,
609
+ cell_size,
573
610
  game_data.stage.survivor_spawn_rate,
574
611
  )
575
612
  for pos in positions:
@@ -586,6 +623,7 @@ def spawn_survivors(
586
623
  if game_data.player:
587
624
  buddies = place_buddies(
588
625
  walkable,
626
+ cell_size,
589
627
  game_data.player,
590
628
  cars=game_data.waiting_cars,
591
629
  count=buddy_count,
@@ -602,17 +640,18 @@ def spawn_survivors(
602
640
 
603
641
  def setup_player_and_cars(
604
642
  game_data: GameData,
605
- layout_data: Mapping[str, list[pygame.Rect]],
643
+ layout_data: Mapping[str, list[tuple[int, int]]],
606
644
  *,
607
645
  car_count: int = 1,
608
646
  ) -> tuple[Player, list[Car]]:
609
647
  """Create the player plus one or more parked cars using blueprint candidates."""
610
648
  all_sprites = game_data.groups.all_sprites
611
- walkable_cells: list[pygame.Rect] = layout_data["walkable_cells"]
649
+ walkable_cells: list[tuple[int, int]] = layout_data["walkable_cells"]
650
+ cell_size = game_data.cell_size
612
651
 
613
- def _pick_center(cells: list[pygame.Rect]) -> tuple[int, int]:
652
+ def _pick_center(cells: list[tuple[int, int]]) -> tuple[int, int]:
614
653
  return (
615
- RNG.choice(cells).center
654
+ _cell_center(RNG.choice(cells), cell_size)
616
655
  if cells
617
656
  else (game_data.level_width // 2, game_data.level_height // 2)
618
657
  )
@@ -620,7 +659,8 @@ def setup_player_and_cars(
620
659
  player_pos = _pick_center(layout_data["player_cells"] or walkable_cells)
621
660
  player = Player(*player_pos)
622
661
 
623
- car_candidates = list(layout_data["car_cells"] or walkable_cells)
662
+ car_walkable = layout_data.get("car_walkable_cells") or walkable_cells
663
+ car_candidates = list(layout_data["car_cells"] or car_walkable)
624
664
  waiting_cars: list[Car] = []
625
665
  car_appearance = _car_appearance_for_stage(game_data.stage)
626
666
 
@@ -630,13 +670,14 @@ def setup_player_and_cars(
630
670
  return (player_pos[0] + 200, player_pos[1])
631
671
  RNG.shuffle(car_candidates)
632
672
  for candidate in car_candidates:
633
- if (candidate.centerx - player_pos[0]) ** 2 + (
634
- candidate.centery - player_pos[1]
673
+ center = _cell_center(candidate, cell_size)
674
+ if (center[0] - player_pos[0]) ** 2 + (
675
+ center[1] - player_pos[1]
635
676
  ) ** 2 >= 400 * 400:
636
677
  car_candidates.remove(candidate)
637
- return candidate.center
678
+ return center
638
679
  choice = car_candidates.pop()
639
- return choice.center
680
+ return _cell_center(choice, cell_size)
640
681
 
641
682
  for _ in range(max(1, car_count)):
642
683
  car_pos = _pick_car_position()
@@ -653,7 +694,7 @@ def setup_player_and_cars(
653
694
  def spawn_initial_zombies(
654
695
  game_data: GameData,
655
696
  player: Player,
656
- layout_data: Mapping[str, list[pygame.Rect]],
697
+ layout_data: Mapping[str, list[tuple[int, int]]],
657
698
  config: dict[str, Any],
658
699
  ) -> None:
659
700
  """Spawn initial zombies using blueprint candidate cells."""
@@ -664,27 +705,28 @@ def spawn_initial_zombies(
664
705
  spawn_cells = layout_data["walkable_cells"]
665
706
  if not spawn_cells:
666
707
  return
708
+ cell_size = game_data.cell_size
667
709
 
668
710
  if game_data.stage.id == "debug_tracker":
669
711
  player_pos = player.rect.center
670
712
  min_dist_sq = 100 * 100
671
713
  max_dist_sq = 240 * 240
672
- candidates = [
673
- cell
674
- for cell in spawn_cells
675
- if min_dist_sq
676
- <= (cell.centerx - player_pos[0]) ** 2 + (cell.centery - player_pos[1]) ** 2
677
- <= max_dist_sq
678
- ]
714
+ candidates = []
715
+ for cell in spawn_cells:
716
+ center = _cell_center(cell, cell_size)
717
+ dist_sq = (center[0] - player_pos[0]) ** 2 + (center[1] - player_pos[1]) ** 2
718
+ if min_dist_sq <= dist_sq <= max_dist_sq:
719
+ candidates.append(cell)
679
720
  if not candidates:
680
721
  candidates = spawn_cells
681
722
  candidate = RNG.choice(candidates)
723
+ candidate_center = _cell_center(candidate, cell_size)
682
724
  tentative = _create_zombie(
683
725
  config,
684
- start_pos=candidate.center,
726
+ start_pos=candidate_center,
685
727
  stage=game_data.stage,
686
728
  tracker=True,
687
- wall_follower=False,
729
+ wall_hugging=False,
688
730
  )
689
731
  if not spritecollideany_walls(tentative, wall_group):
690
732
  zombie_group.add(tentative)
@@ -696,19 +738,20 @@ def spawn_initial_zombies(
696
738
  spawn_rate = max(0.0, game_data.stage.initial_interior_spawn_rate)
697
739
  positions = find_interior_spawn_positions(
698
740
  spawn_cells,
741
+ cell_size,
699
742
  spawn_rate,
700
743
  player=player,
701
744
  min_player_dist=ZOMBIE_SPAWN_PLAYER_BUFFER,
702
745
  )
703
746
 
704
747
  for pos in positions:
705
- tracker, wall_follower = _pick_zombie_variant(game_data.stage)
748
+ tracker, wall_hugging = _pick_zombie_variant(game_data.stage)
706
749
  tentative = _create_zombie(
707
750
  config,
708
751
  start_pos=pos,
709
752
  stage=game_data.stage,
710
753
  tracker=tracker,
711
- wall_follower=wall_follower,
754
+ wall_hugging=wall_hugging,
712
755
  )
713
756
  if spritecollideany_walls(tentative, wall_group):
714
757
  continue
@@ -724,11 +767,14 @@ def spawn_waiting_car(game_data: GameData) -> Car | None:
724
767
  player = game_data.player
725
768
  if not player:
726
769
  return None
727
- walkable_cells = game_data.layout.walkable_cells
770
+ # Use cells that are 4-way reachable by car
771
+ car_walkable = list(game_data.layout.car_walkable_cells)
772
+ walkable_cells = car_walkable if car_walkable else game_data.layout.walkable_cells
728
773
  if not walkable_cells:
729
774
  return None
730
775
  wall_group = game_data.groups.wall_group
731
776
  all_sprites = game_data.groups.all_sprites
777
+ cell_size = game_data.cell_size
732
778
  active_car = game_data.car if game_data.car and game_data.car.alive() else None
733
779
  waiting = _alive_waiting_cars(game_data)
734
780
  obstacles: list[Car] = list(waiting)
@@ -742,6 +788,7 @@ def spawn_waiting_car(game_data: GameData) -> Car | None:
742
788
  wall_group,
743
789
  player,
744
790
  walkable_cells,
791
+ cell_size,
745
792
  existing_cars=obstacles,
746
793
  appearance=appearance,
747
794
  )
@@ -813,6 +860,7 @@ def _spawn_nearby_zombie(
813
860
  all_sprites = game_data.groups.all_sprites
814
861
  spawn_pos = find_nearby_offscreen_spawn_position(
815
862
  game_data.layout.walkable_cells,
863
+ game_data.cell_size,
816
864
  player=player,
817
865
  camera=camera,
818
866
  min_player_dist=ZOMBIE_SPAWN_PLAYER_BUFFER,
@@ -877,18 +925,19 @@ def update_falling_zombies(game_data: GameData, config: dict[str, Any]) -> None:
877
925
  fall.dust_started = True
878
926
  if now < spawn_at:
879
927
  continue
880
- if len(zombie_group) >= MAX_ZOMBIES:
881
- state.falling_zombies.remove(fall)
882
- continue
883
- candidate = _create_zombie(
884
- config,
885
- start_pos=fall.target_pos,
886
- stage=game_data.stage,
887
- tracker=fall.tracker,
888
- wall_follower=fall.wall_follower,
889
- )
890
- zombie_group.add(candidate)
891
- all_sprites.add(candidate, layer=1)
928
+
929
+ if getattr(fall, "mode", "spawn") == "spawn":
930
+ if len(zombie_group) < MAX_ZOMBIES:
931
+ candidate = _create_zombie(
932
+ config,
933
+ start_pos=fall.target_pos,
934
+ stage=game_data.stage,
935
+ tracker=fall.tracker,
936
+ wall_hugging=fall.wall_hugging,
937
+ )
938
+ zombie_group.add(candidate)
939
+ all_sprites.add(candidate, layer=1)
940
+
892
941
  state.falling_zombies.remove(fall)
893
942
 
894
943
 
@@ -51,9 +51,12 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
51
51
  last_zombie_spawn_time=0,
52
52
  dawn_carbonized=False,
53
53
  debug_mode=False,
54
+ show_fps=False,
54
55
  falling_zombies=[],
55
56
  falling_spawn_carry=0,
56
57
  dust_rings=[],
58
+ player_wall_target_cell=None,
59
+ player_wall_target_ttl=0,
57
60
  )
58
61
 
59
62
  # Create sprite groups
@@ -69,8 +72,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
69
72
  camera = Camera(level_width, level_height)
70
73
 
71
74
  # Define level layout (will be filled by blueprint generation)
72
- outer_rect = 0, 0, level_width, level_height
73
- inner_rect = outer_rect
75
+ field_rect = pygame.Rect(0, 0, level_width, level_height)
74
76
 
75
77
  return GameData(
76
78
  state=game_state,
@@ -82,12 +84,13 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
82
84
  ),
83
85
  camera=camera,
84
86
  layout=LevelLayout(
85
- outer_rect=outer_rect,
86
- inner_rect=inner_rect,
87
- outside_rects=[],
87
+ field_rect=field_rect,
88
+ outside_cells=set(),
88
89
  walkable_cells=[],
89
90
  outer_wall_cells=set(),
90
91
  wall_cells=set(),
92
+ pitfall_cells=set(),
93
+ car_walkable_cells=set(),
91
94
  fall_spawn_cells=set(),
92
95
  bevel_corners={},
93
96
  ),
@@ -107,8 +110,11 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
107
110
 
108
111
  def carbonize_outdoor_zombies(game_data: GameData) -> None:
109
112
  """Petrify zombies that have already broken through to the exterior."""
110
- outside_rects = game_data.layout.outside_rects or []
111
- if not outside_rects:
113
+ outside_cells = game_data.layout.outside_cells
114
+ if not outside_cells:
115
+ return
116
+ cell_size = game_data.cell_size
117
+ if cell_size <= 0:
112
118
  return
113
119
  group = game_data.groups.zombie_group
114
120
  if not group:
@@ -116,8 +122,11 @@ def carbonize_outdoor_zombies(game_data: GameData) -> None:
116
122
  for zombie in list(group):
117
123
  if not zombie.alive():
118
124
  continue
119
- center = zombie.rect.center
120
- if any(rect_obj.collidepoint(center) for rect_obj in outside_rects):
125
+ cell = (
126
+ int(zombie.rect.centerx // cell_size),
127
+ int(zombie.rect.centery // cell_size),
128
+ )
129
+ if cell in outside_cells:
121
130
  zombie.carbonize()
122
131
 
123
132