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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/entities.py +501 -537
  3. zombie_escape/entities_constants.py +102 -0
  4. zombie_escape/gameplay/__init__.py +75 -2
  5. zombie_escape/gameplay/ambient.py +50 -0
  6. zombie_escape/gameplay/constants.py +46 -0
  7. zombie_escape/gameplay/footprints.py +60 -0
  8. zombie_escape/gameplay/interactions.py +354 -0
  9. zombie_escape/gameplay/layout.py +190 -0
  10. zombie_escape/gameplay/movement.py +220 -0
  11. zombie_escape/gameplay/spawn.py +618 -0
  12. zombie_escape/gameplay/state.py +137 -0
  13. zombie_escape/gameplay/survivors.py +306 -0
  14. zombie_escape/gameplay/utils.py +147 -0
  15. zombie_escape/gameplay_constants.py +0 -148
  16. zombie_escape/level_blueprints.py +123 -10
  17. zombie_escape/level_constants.py +6 -13
  18. zombie_escape/locales/ui.en.json +10 -1
  19. zombie_escape/locales/ui.ja.json +10 -1
  20. zombie_escape/models.py +15 -9
  21. zombie_escape/render.py +42 -27
  22. zombie_escape/render_assets.py +533 -23
  23. zombie_escape/render_constants.py +57 -22
  24. zombie_escape/rng.py +9 -9
  25. zombie_escape/screens/__init__.py +59 -29
  26. zombie_escape/screens/game_over.py +3 -3
  27. zombie_escape/screens/gameplay.py +45 -27
  28. zombie_escape/screens/title.py +5 -2
  29. zombie_escape/stage_constants.py +34 -1
  30. zombie_escape/zombie_escape.py +30 -12
  31. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/METADATA +1 -1
  32. zombie_escape-1.7.1.dist-info/RECORD +45 -0
  33. zombie_escape/gameplay/logic.py +0 -1917
  34. zombie_escape-1.5.4.dist-info/RECORD +0 -35
  35. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/WHEEL +0 -0
  36. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/entry_points.txt +0 -0
  37. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,618 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, Sequence
4
+
5
+ import pygame
6
+
7
+ from ..entities import (
8
+ Car,
9
+ Flashlight,
10
+ FuelCan,
11
+ Player,
12
+ Survivor,
13
+ Zombie,
14
+ random_position_outside_building,
15
+ spritecollideany_walls,
16
+ )
17
+ from ..entities_constants import (
18
+ FAST_ZOMBIE_BASE_SPEED,
19
+ PLAYER_SPEED,
20
+ ZOMBIE_AGING_DURATION_FRAMES,
21
+ ZOMBIE_SPEED,
22
+ )
23
+ from ..gameplay_constants import DEFAULT_FLASHLIGHT_SPAWN_COUNT
24
+ from ..level_constants import DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TILE_SIZE
25
+ from ..models import GameData, Stage
26
+ from ..rng import get_rng
27
+ from .constants import (
28
+ MAX_ZOMBIES,
29
+ ZOMBIE_SPAWN_PLAYER_BUFFER,
30
+ ZOMBIE_TRACKER_AGING_DURATION_FRAMES,
31
+ )
32
+ from .utils import (
33
+ find_exterior_spawn_position,
34
+ find_interior_spawn_positions,
35
+ find_nearby_offscreen_spawn_position,
36
+ rect_visible_on_screen,
37
+ )
38
+
39
+ RNG = get_rng()
40
+
41
+ __all__ = [
42
+ "place_new_car",
43
+ "place_fuel_can",
44
+ "place_flashlights",
45
+ "place_buddies",
46
+ "spawn_survivors",
47
+ "setup_player_and_cars",
48
+ "spawn_initial_zombies",
49
+ "spawn_waiting_car",
50
+ "maintain_waiting_car_supply",
51
+ "nearest_waiting_car",
52
+ "spawn_exterior_zombie",
53
+ "spawn_weighted_zombie",
54
+ ]
55
+
56
+
57
+ def _car_appearance_for_stage(stage: Stage | None) -> str:
58
+ return "disabled" if stage and stage.survival_stage else "default"
59
+
60
+
61
+ def _create_zombie(
62
+ config: dict[str, Any],
63
+ *,
64
+ start_pos: tuple[int, int] | None = None,
65
+ hint_pos: tuple[float, float] | None = None,
66
+ stage: Stage | None = None,
67
+ tracker: bool | None = None,
68
+ wall_follower: bool | None = None,
69
+ ) -> Zombie:
70
+ """Factory to create zombies with optional fast variants."""
71
+ fast_conf = config.get("fast_zombies", {})
72
+ fast_enabled = fast_conf.get("enabled", True)
73
+ if fast_enabled:
74
+ base_speed = RNG.uniform(ZOMBIE_SPEED, FAST_ZOMBIE_BASE_SPEED)
75
+ else:
76
+ base_speed = ZOMBIE_SPEED
77
+ base_speed = min(base_speed, PLAYER_SPEED - 0.05)
78
+ normal_ratio = 1.0
79
+ tracker_ratio = 0.0
80
+ wall_follower_ratio = 0.0
81
+ if stage is not None:
82
+ normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
83
+ tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
84
+ wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
85
+ if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
86
+ normal_ratio = 1.0
87
+ tracker_ratio = 0.0
88
+ wall_follower_ratio = 0.0
89
+ if (
90
+ normal_ratio == 1.0
91
+ and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
92
+ and tracker_ratio + wall_follower_ratio <= 1.0
93
+ ):
94
+ normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
95
+ aging_duration_frames = max(
96
+ 1.0,
97
+ float(stage.zombie_aging_duration_frames),
98
+ )
99
+ else:
100
+ aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
101
+ picked_tracker = False
102
+ picked_wall_follower = False
103
+ total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
104
+ if total_ratio > 0:
105
+ pick = RNG.random() * total_ratio
106
+ if pick < normal_ratio:
107
+ pass
108
+ elif pick < normal_ratio + tracker_ratio:
109
+ picked_tracker = True
110
+ else:
111
+ picked_wall_follower = True
112
+ if tracker is None:
113
+ tracker = picked_tracker
114
+ if wall_follower is None:
115
+ wall_follower = picked_wall_follower
116
+ if tracker:
117
+ wall_follower = False
118
+ if tracker:
119
+ ratio = (
120
+ ZOMBIE_TRACKER_AGING_DURATION_FRAMES / ZOMBIE_AGING_DURATION_FRAMES
121
+ if ZOMBIE_AGING_DURATION_FRAMES > 0
122
+ else 1.0
123
+ )
124
+ aging_duration_frames = max(1.0, aging_duration_frames * ratio)
125
+ if start_pos is None:
126
+ tile_size = stage.tile_size if stage else DEFAULT_TILE_SIZE
127
+ if stage is None:
128
+ grid_cols = DEFAULT_GRID_COLS
129
+ grid_rows = DEFAULT_GRID_ROWS
130
+ else:
131
+ grid_cols = stage.grid_cols
132
+ grid_rows = stage.grid_rows
133
+ level_width = grid_cols * tile_size
134
+ level_height = grid_rows * tile_size
135
+ if hint_pos is not None:
136
+ points = [
137
+ random_position_outside_building(level_width, level_height)
138
+ for _ in range(5)
139
+ ]
140
+ points.sort(
141
+ key=lambda p: (p[0] - hint_pos[0]) ** 2 + (p[1] - hint_pos[1]) ** 2
142
+ )
143
+ start_pos = points[0]
144
+ else:
145
+ start_pos = random_position_outside_building(level_width, level_height)
146
+ return Zombie(
147
+ x=float(start_pos[0]),
148
+ y=float(start_pos[1]),
149
+ speed=base_speed,
150
+ tracker=tracker,
151
+ wall_follower=wall_follower,
152
+ aging_duration_frames=aging_duration_frames,
153
+ )
154
+
155
+
156
+ def place_fuel_can(
157
+ walkable_cells: list[pygame.Rect],
158
+ player: Player,
159
+ *,
160
+ cars: Sequence[Car] | None = None,
161
+ count: int = 1,
162
+ ) -> FuelCan | None:
163
+ """Pick a spawn spot for the fuel can away from the player (and car if given)."""
164
+ if count <= 0 or not walkable_cells:
165
+ return None
166
+
167
+ min_player_dist = 250
168
+ min_car_dist = 200
169
+ min_player_dist_sq = min_player_dist * min_player_dist
170
+ min_car_dist_sq = min_car_dist * min_car_dist
171
+
172
+ for _ in range(200):
173
+ cell = RNG.choice(walkable_cells)
174
+ dx = cell.centerx - player.x
175
+ dy = cell.centery - player.y
176
+ if dx * dx + dy * dy < min_player_dist_sq:
177
+ continue
178
+ if cars:
179
+ too_close = False
180
+ for parked_car in cars:
181
+ dx = cell.centerx - parked_car.rect.centerx
182
+ dy = cell.centery - parked_car.rect.centery
183
+ if dx * dx + dy * dy < min_car_dist_sq:
184
+ too_close = True
185
+ break
186
+ if too_close:
187
+ continue
188
+ return FuelCan(cell.centerx, cell.centery)
189
+
190
+ cell = RNG.choice(walkable_cells)
191
+ return FuelCan(cell.centerx, cell.centery)
192
+
193
+
194
+ def _place_flashlight(
195
+ walkable_cells: list[pygame.Rect],
196
+ player: Player,
197
+ *,
198
+ cars: Sequence[Car] | None = None,
199
+ ) -> Flashlight | None:
200
+ """Pick a spawn spot for the flashlight away from the player (and car if given)."""
201
+ if not walkable_cells:
202
+ return None
203
+
204
+ min_player_dist = 260
205
+ min_car_dist = 200
206
+ min_player_dist_sq = min_player_dist * min_player_dist
207
+ min_car_dist_sq = min_car_dist * min_car_dist
208
+
209
+ for _ in range(200):
210
+ cell = RNG.choice(walkable_cells)
211
+ dx = cell.centerx - player.x
212
+ dy = cell.centery - player.y
213
+ if dx * dx + dy * dy < min_player_dist_sq:
214
+ continue
215
+ if cars:
216
+ if any(
217
+ (cell.centerx - parked.rect.centerx) ** 2
218
+ + (cell.centery - parked.rect.centery) ** 2
219
+ < min_car_dist_sq
220
+ for parked in cars
221
+ ):
222
+ continue
223
+ return Flashlight(cell.centerx, cell.centery)
224
+
225
+ cell = RNG.choice(walkable_cells)
226
+ return Flashlight(cell.centerx, cell.centery)
227
+
228
+
229
+ def place_flashlights(
230
+ walkable_cells: list[pygame.Rect],
231
+ player: Player,
232
+ *,
233
+ cars: Sequence[Car] | None = None,
234
+ count: int = DEFAULT_FLASHLIGHT_SPAWN_COUNT,
235
+ ) -> list[Flashlight]:
236
+ """Spawn multiple flashlights using the single-place helper to spread them out."""
237
+ placed: list[Flashlight] = []
238
+ attempts = 0
239
+ max_attempts = max(200, count * 80)
240
+ while len(placed) < count and attempts < max_attempts:
241
+ attempts += 1
242
+ fl = _place_flashlight(walkable_cells, player, cars=cars)
243
+ if not fl:
244
+ break
245
+ # Avoid clustering too tightly
246
+ if any(
247
+ (other.rect.centerx - fl.rect.centerx) ** 2
248
+ + (other.rect.centery - fl.rect.centery) ** 2
249
+ < 120 * 120
250
+ for other in placed
251
+ ):
252
+ continue
253
+ placed.append(fl)
254
+ return placed
255
+
256
+
257
+ def place_buddies(
258
+ walkable_cells: list[pygame.Rect],
259
+ player: Player,
260
+ *,
261
+ cars: Sequence[Car] | None = None,
262
+ count: int = 1,
263
+ ) -> list[Survivor]:
264
+ placed: list[Survivor] = []
265
+ if count <= 0 or not walkable_cells:
266
+ return placed
267
+ min_player_dist = 240
268
+ positions = find_interior_spawn_positions(
269
+ walkable_cells,
270
+ 1.0,
271
+ player=player,
272
+ min_player_dist=min_player_dist,
273
+ )
274
+ RNG.shuffle(positions)
275
+ for pos in positions[:count]:
276
+ placed.append(Survivor(pos[0], pos[1], is_buddy=True))
277
+ remaining = count - len(placed)
278
+ for _ in range(max(0, remaining)):
279
+ spawn_pos = find_nearby_offscreen_spawn_position(walkable_cells)
280
+ placed.append(Survivor(spawn_pos[0], spawn_pos[1], is_buddy=True))
281
+ return placed
282
+
283
+
284
+ def place_new_car(
285
+ wall_group: pygame.sprite.Group,
286
+ player: Player,
287
+ walkable_cells: list[pygame.Rect],
288
+ *,
289
+ existing_cars: Sequence[Car] | None = None,
290
+ appearance: str = "default",
291
+ ) -> Car | None:
292
+ if not walkable_cells:
293
+ return None
294
+
295
+ max_attempts = 150
296
+ for _ in range(max_attempts):
297
+ cell = RNG.choice(walkable_cells)
298
+ c_x, c_y = cell.center
299
+ temp_car = Car(c_x, c_y, appearance=appearance)
300
+ temp_rect = temp_car.rect.inflate(30, 30)
301
+ nearby_walls = pygame.sprite.Group()
302
+ nearby_walls.add(
303
+ [
304
+ w
305
+ for w in wall_group
306
+ if abs(w.rect.centerx - c_x) < 150 and abs(w.rect.centery - c_y) < 150
307
+ ]
308
+ )
309
+ collides_wall = spritecollideany_walls(temp_car, nearby_walls)
310
+ collides_player = temp_rect.colliderect(player.rect.inflate(50, 50))
311
+ car_overlap = False
312
+ if existing_cars:
313
+ car_overlap = any(
314
+ temp_car.rect.colliderect(other.rect)
315
+ for other in existing_cars
316
+ if other and other.alive()
317
+ )
318
+ if not collides_wall and not collides_player and not car_overlap:
319
+ return temp_car
320
+ return None
321
+
322
+
323
+ def spawn_survivors(
324
+ game_data: GameData, layout_data: Mapping[str, list[pygame.Rect]]
325
+ ) -> list[Survivor]:
326
+ """Populate rescue-stage survivors and buddy-stage buddies."""
327
+ survivors: list[Survivor] = []
328
+ if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
329
+ return survivors
330
+
331
+ walkable = layout_data.get("walkable_cells", [])
332
+ wall_group = game_data.groups.wall_group
333
+ survivor_group = game_data.groups.survivor_group
334
+ all_sprites = game_data.groups.all_sprites
335
+
336
+ if game_data.stage.rescue_stage:
337
+ positions = find_interior_spawn_positions(
338
+ walkable,
339
+ game_data.stage.survivor_spawn_rate,
340
+ )
341
+ for pos in positions:
342
+ survivor = Survivor(*pos)
343
+ if spritecollideany_walls(survivor, wall_group):
344
+ continue
345
+ survivor_group.add(survivor)
346
+ all_sprites.add(survivor, layer=1)
347
+ survivors.append(survivor)
348
+
349
+ if game_data.stage.buddy_required_count > 0:
350
+ buddy_count = max(0, game_data.stage.buddy_required_count)
351
+ buddies: list[Survivor] = []
352
+ if game_data.player:
353
+ buddies = place_buddies(
354
+ walkable,
355
+ game_data.player,
356
+ cars=game_data.waiting_cars,
357
+ count=buddy_count,
358
+ )
359
+ for buddy in buddies:
360
+ if spritecollideany_walls(buddy, wall_group):
361
+ continue
362
+ survivor_group.add(buddy)
363
+ all_sprites.add(buddy, layer=2)
364
+ survivors.append(buddy)
365
+
366
+ return survivors
367
+
368
+
369
+ def setup_player_and_cars(
370
+ game_data: GameData,
371
+ layout_data: Mapping[str, list[pygame.Rect]],
372
+ *,
373
+ car_count: int = 1,
374
+ ) -> tuple[Player, list[Car]]:
375
+ """Create the player plus one or more parked cars using blueprint candidates."""
376
+ all_sprites = game_data.groups.all_sprites
377
+ walkable_cells: list[pygame.Rect] = layout_data["walkable_cells"]
378
+
379
+ def _pick_center(cells: list[pygame.Rect]) -> tuple[int, int]:
380
+ return (
381
+ RNG.choice(cells).center
382
+ if cells
383
+ else (game_data.level_width // 2, game_data.level_height // 2)
384
+ )
385
+
386
+ player_pos = _pick_center(layout_data["player_cells"] or walkable_cells)
387
+ player = Player(*player_pos)
388
+
389
+ car_candidates = list(layout_data["car_cells"] or walkable_cells)
390
+ waiting_cars: list[Car] = []
391
+ car_appearance = _car_appearance_for_stage(game_data.stage)
392
+
393
+ def _pick_car_position() -> tuple[int, int]:
394
+ """Favor distant cells for the first car, otherwise fall back to random picks."""
395
+ if not car_candidates:
396
+ return (player_pos[0] + 200, player_pos[1])
397
+ RNG.shuffle(car_candidates)
398
+ for candidate in car_candidates:
399
+ if (candidate.centerx - player_pos[0]) ** 2 + (
400
+ candidate.centery - player_pos[1]
401
+ ) ** 2 >= 400 * 400:
402
+ car_candidates.remove(candidate)
403
+ return candidate.center
404
+ choice = car_candidates.pop()
405
+ return choice.center
406
+
407
+ for _ in range(max(1, car_count)):
408
+ car_pos = _pick_car_position()
409
+ car = Car(*car_pos, appearance=car_appearance)
410
+ waiting_cars.append(car)
411
+ all_sprites.add(car, layer=1)
412
+ if not car_candidates:
413
+ break
414
+
415
+ all_sprites.add(player, layer=2)
416
+ return player, waiting_cars
417
+
418
+
419
+ def spawn_initial_zombies(
420
+ game_data: GameData,
421
+ player: Player,
422
+ layout_data: Mapping[str, list[pygame.Rect]],
423
+ config: dict[str, Any],
424
+ ) -> None:
425
+ """Spawn initial zombies using blueprint candidate cells."""
426
+ wall_group = game_data.groups.wall_group
427
+ zombie_group = game_data.groups.zombie_group
428
+ all_sprites = game_data.groups.all_sprites
429
+
430
+ spawn_cells = layout_data["walkable_cells"]
431
+ if not spawn_cells:
432
+ return
433
+
434
+ spawn_rate = max(0.0, game_data.stage.initial_interior_spawn_rate)
435
+ positions = find_interior_spawn_positions(
436
+ spawn_cells,
437
+ spawn_rate,
438
+ player=player,
439
+ min_player_dist=ZOMBIE_SPAWN_PLAYER_BUFFER,
440
+ )
441
+
442
+ for pos in positions:
443
+ tentative = _create_zombie(
444
+ config,
445
+ start_pos=pos,
446
+ stage=game_data.stage,
447
+ )
448
+ if spritecollideany_walls(tentative, wall_group):
449
+ continue
450
+ zombie_group.add(tentative)
451
+ all_sprites.add(tentative, layer=1)
452
+
453
+ interval = max(1, game_data.stage.spawn_interval_ms)
454
+ game_data.state.last_zombie_spawn_time = pygame.time.get_ticks() - interval
455
+
456
+
457
+ def spawn_waiting_car(game_data: GameData) -> Car | None:
458
+ """Attempt to place an additional parked car on the map."""
459
+ player = game_data.player
460
+ if not player:
461
+ return None
462
+ walkable_cells = game_data.layout.walkable_cells
463
+ if not walkable_cells:
464
+ return None
465
+ wall_group = game_data.groups.wall_group
466
+ all_sprites = game_data.groups.all_sprites
467
+ active_car = game_data.car if game_data.car and game_data.car.alive() else None
468
+ waiting = _alive_waiting_cars(game_data)
469
+ obstacles: list[Car] = list(waiting)
470
+ if active_car:
471
+ obstacles.append(active_car)
472
+ camera = game_data.camera
473
+ appearance = _car_appearance_for_stage(game_data.stage)
474
+ offscreen_attempts = 6
475
+ while offscreen_attempts > 0:
476
+ new_car = place_new_car(
477
+ wall_group,
478
+ player,
479
+ walkable_cells,
480
+ existing_cars=obstacles,
481
+ appearance=appearance,
482
+ )
483
+ if not new_car:
484
+ return None
485
+ if rect_visible_on_screen(camera, new_car.rect):
486
+ offscreen_attempts -= 1
487
+ continue
488
+ game_data.waiting_cars.append(new_car)
489
+ all_sprites.add(new_car, layer=1)
490
+ return new_car
491
+ return None
492
+
493
+
494
+ def maintain_waiting_car_supply(
495
+ game_data: GameData, *, minimum: int | None = None
496
+ ) -> None:
497
+ """Ensure a baseline count of parked cars exists."""
498
+ target = 1 if minimum is None else max(0, minimum)
499
+ current = len(_alive_waiting_cars(game_data))
500
+ while current < target:
501
+ new_car = spawn_waiting_car(game_data)
502
+ if not new_car:
503
+ break
504
+ current += 1
505
+
506
+
507
+ def _alive_waiting_cars(game_data: GameData) -> list[Car]:
508
+ """Return the list of parked cars that still exist, pruning any destroyed sprites."""
509
+ cars = [car for car in game_data.waiting_cars if car.alive()]
510
+ game_data.waiting_cars = cars
511
+ _log_waiting_car_count(game_data)
512
+ return cars
513
+
514
+
515
+ def _log_waiting_car_count(game_data: GameData, *, force: bool = False) -> None:
516
+ """Print the number of waiting cars when it changes."""
517
+ current = len(game_data.waiting_cars)
518
+ if not force and current == game_data.last_logged_waiting_cars:
519
+ return
520
+ game_data.last_logged_waiting_cars = current
521
+
522
+
523
+ def nearest_waiting_car(game_data: GameData, origin: tuple[float, float]) -> Car | None:
524
+ """Find the closest waiting car to an origin point."""
525
+ cars = _alive_waiting_cars(game_data)
526
+ if not cars:
527
+ return None
528
+ return min(
529
+ cars,
530
+ key=lambda car: (car.rect.centerx - origin[0]) ** 2
531
+ + (car.rect.centery - origin[1]) ** 2,
532
+ )
533
+
534
+
535
+ def _spawn_nearby_zombie(
536
+ game_data: GameData,
537
+ config: dict[str, Any],
538
+ ) -> Zombie | None:
539
+ """Spawn a zombie just outside of the current camera frustum."""
540
+ player = game_data.player
541
+ if not player:
542
+ return None
543
+ zombie_group = game_data.groups.zombie_group
544
+ if len(zombie_group) >= MAX_ZOMBIES:
545
+ return None
546
+ camera = game_data.camera
547
+ wall_group = game_data.groups.wall_group
548
+ all_sprites = game_data.groups.all_sprites
549
+ spawn_pos = find_nearby_offscreen_spawn_position(
550
+ game_data.layout.walkable_cells,
551
+ player=player,
552
+ camera=camera,
553
+ attempts=50,
554
+ )
555
+ new_zombie = _create_zombie(
556
+ config,
557
+ start_pos=spawn_pos,
558
+ stage=game_data.stage,
559
+ )
560
+ if spritecollideany_walls(new_zombie, wall_group):
561
+ return None
562
+ zombie_group.add(new_zombie)
563
+ all_sprites.add(new_zombie, layer=1)
564
+ return new_zombie
565
+
566
+
567
+ def spawn_exterior_zombie(
568
+ game_data: GameData,
569
+ config: dict[str, Any],
570
+ ) -> Zombie | None:
571
+ """Spawn a zombie using the standard exterior hint logic."""
572
+ player = game_data.player
573
+ if not player:
574
+ return None
575
+ zombie_group = game_data.groups.zombie_group
576
+ all_sprites = game_data.groups.all_sprites
577
+ spawn_pos = find_exterior_spawn_position(
578
+ game_data.level_width,
579
+ game_data.level_height,
580
+ hint_pos=(player.x, player.y),
581
+ )
582
+ new_zombie = _create_zombie(
583
+ config,
584
+ start_pos=spawn_pos,
585
+ stage=game_data.stage,
586
+ )
587
+ zombie_group.add(new_zombie)
588
+ all_sprites.add(new_zombie, layer=1)
589
+ return new_zombie
590
+
591
+
592
+ def spawn_weighted_zombie(
593
+ game_data: GameData,
594
+ config: dict[str, Any],
595
+ ) -> bool:
596
+ """Spawn a zombie according to the stage's interior/exterior mix."""
597
+ stage = game_data.stage
598
+
599
+ def _spawn(choice: str) -> bool:
600
+ if choice == "interior":
601
+ return _spawn_nearby_zombie(game_data, config) is not None
602
+ return spawn_exterior_zombie(game_data, config) is not None
603
+
604
+ interior_weight = max(0.0, stage.interior_spawn_weight)
605
+ exterior_weight = max(0.0, stage.exterior_spawn_weight)
606
+ total_weight = interior_weight + exterior_weight
607
+ if total_weight <= 0:
608
+ # Fall back to exterior spawns if weights are unset or invalid.
609
+ return _spawn("exterior")
610
+
611
+ pick = RNG.uniform(0, total_weight)
612
+ if pick <= interior_weight:
613
+ if _spawn("interior"):
614
+ return True
615
+ return _spawn("exterior")
616
+ if _spawn("exterior"):
617
+ return True
618
+ return _spawn("interior")