zombie-escape 1.10.0__py3-none-any.whl → 1.12.0__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.
@@ -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.10.0"
4
+ __version__ = "1.12.0"
zombie_escape/colors.py CHANGED
@@ -72,37 +72,39 @@ _DEFAULT_ENVIRONMENT_PALETTE = EnvironmentPalette(
72
72
  # Dark, desaturated palette that sells the "alone without a flashlight" vibe.
73
73
  _GLOOM_ENVIRONMENT_PALETTE = EnvironmentPalette(
74
74
  floor_primary=_adjust_color(
75
- _DEFAULT_ENVIRONMENT_PALETTE.floor_primary, brightness=0.725, saturation=0.675
75
+ _DEFAULT_ENVIRONMENT_PALETTE.floor_primary, brightness=0.8, saturation=0.75
76
76
  ),
77
77
  floor_secondary=_adjust_color(
78
- _DEFAULT_ENVIRONMENT_PALETTE.floor_secondary, brightness=0.74, saturation=0.65
78
+ _DEFAULT_ENVIRONMENT_PALETTE.floor_secondary, brightness=0.8, saturation=0.75
79
79
  ),
80
80
  fall_zone_primary=_adjust_color(
81
81
  _DEFAULT_ENVIRONMENT_PALETTE.fall_zone_primary,
82
- brightness=0.725,
83
- saturation=0.675,
82
+ brightness=0.8,
83
+ saturation=0.75,
84
84
  ),
85
85
  fall_zone_secondary=_adjust_color(
86
86
  _DEFAULT_ENVIRONMENT_PALETTE.fall_zone_secondary,
87
- brightness=0.74,
88
- saturation=0.65,
87
+ brightness=0.8,
88
+ saturation=0.75,
89
89
  ),
90
90
  outside=_adjust_color(
91
- _DEFAULT_ENVIRONMENT_PALETTE.outside, brightness=0.7, saturation=0.625
91
+ _DEFAULT_ENVIRONMENT_PALETTE.outside, brightness=0.8, saturation=0.75
92
92
  ),
93
93
  inner_wall=_adjust_color(
94
- _DEFAULT_ENVIRONMENT_PALETTE.inner_wall, brightness=0.775, saturation=0.7
94
+ _DEFAULT_ENVIRONMENT_PALETTE.inner_wall, brightness=0.8, saturation=0.75
95
95
  ),
96
96
  inner_wall_border=_adjust_color(
97
- _DEFAULT_ENVIRONMENT_PALETTE.inner_wall_border, brightness=0.775, saturation=0.7
97
+ _DEFAULT_ENVIRONMENT_PALETTE.inner_wall_border,
98
+ brightness=0.8,
99
+ saturation=0.75,
98
100
  ),
99
101
  outer_wall=_adjust_color(
100
- _DEFAULT_ENVIRONMENT_PALETTE.outer_wall, brightness=0.75, saturation=0.675
102
+ _DEFAULT_ENVIRONMENT_PALETTE.outer_wall, brightness=0.8, saturation=0.75
101
103
  ),
102
104
  outer_wall_border=_adjust_color(
103
105
  _DEFAULT_ENVIRONMENT_PALETTE.outer_wall_border,
104
- brightness=0.75,
105
- saturation=0.675,
106
+ brightness=0.8,
107
+ saturation=0.75,
106
108
  ),
107
109
  )
108
110
 
zombie_escape/entities.py CHANGED
@@ -26,6 +26,8 @@ from .entities_constants import (
26
26
  FLASHLIGHT_WIDTH,
27
27
  FUEL_CAN_HEIGHT,
28
28
  FUEL_CAN_WIDTH,
29
+ SHOES_HEIGHT,
30
+ SHOES_WIDTH,
29
31
  INTERNAL_WALL_BEVEL_DEPTH,
30
32
  INTERNAL_WALL_HEALTH,
31
33
  PLAYER_RADIUS,
@@ -63,6 +65,7 @@ from .render_assets import (
63
65
  build_car_surface,
64
66
  build_flashlight_surface,
65
67
  build_fuel_can_surface,
68
+ build_shoes_surface,
66
69
  build_player_surface,
67
70
  build_survivor_surface,
68
71
  build_zombie_surface,
@@ -1589,6 +1592,15 @@ class Flashlight(pygame.sprite.Sprite):
1589
1592
  self.rect = self.image.get_rect(center=(x, y))
1590
1593
 
1591
1594
 
1595
+ class Shoes(pygame.sprite.Sprite):
1596
+ """Shoes pickup that boosts the player's move speed when collected."""
1597
+
1598
+ def __init__(self: Self, x: int, y: int) -> None:
1599
+ super().__init__()
1600
+ self.image = build_shoes_surface(SHOES_WIDTH, SHOES_HEIGHT)
1601
+ self.rect = self.image.get_rect(center=(x, y))
1602
+
1603
+
1592
1604
  def _car_body_radius(width: float, height: float) -> float:
1593
1605
  """Approximate car collision radius using only its own dimensions."""
1594
1606
  return min(width, height) / 2
@@ -1604,5 +1616,6 @@ __all__ = [
1604
1616
  "Car",
1605
1617
  "FuelCan",
1606
1618
  "Flashlight",
1619
+ "Shoes",
1607
1620
  "random_position_outside_building",
1608
1621
  ]
@@ -20,8 +20,12 @@ SURVIVOR_MAX_SAFE_PASSENGERS = 5
20
20
  SURVIVOR_MIN_SPEED_FACTOR = 0.35
21
21
 
22
22
  # --- Flashlight settings ---
23
- FLASHLIGHT_WIDTH = 10
24
- FLASHLIGHT_HEIGHT = 8
23
+ FLASHLIGHT_WIDTH = 12
24
+ FLASHLIGHT_HEIGHT = 10
25
+
26
+ # --- Shoes settings ---
27
+ SHOES_WIDTH = 14
28
+ SHOES_HEIGHT = 12
25
29
 
26
30
  # --- Zombie settings ---
27
31
  ZOMBIE_RADIUS = HUMANOID_RADIUS
@@ -50,7 +54,7 @@ CAR_HEIGHT = 25
50
54
  CAR_SPEED = 2
51
55
  CAR_HEALTH = 20
52
56
  CAR_WALL_DAMAGE = 1
53
- FUEL_CAN_WIDTH = 11
57
+ FUEL_CAN_WIDTH = 12
54
58
  FUEL_CAN_HEIGHT = 15
55
59
 
56
60
  # --- Wall and beam settings ---
@@ -74,6 +78,8 @@ __all__ = [
74
78
  "SURVIVOR_MIN_SPEED_FACTOR",
75
79
  "FLASHLIGHT_WIDTH",
76
80
  "FLASHLIGHT_HEIGHT",
81
+ "SHOES_WIDTH",
82
+ "SHOES_HEIGHT",
77
83
  "ZOMBIE_RADIUS",
78
84
  "ZOMBIE_SPEED",
79
85
  "ZOMBIE_WANDER_INTERVAL_MS",
@@ -14,6 +14,7 @@ from .spawn import (
14
14
  place_flashlights,
15
15
  place_fuel_can,
16
16
  place_new_car,
17
+ place_shoes,
17
18
  setup_player_and_cars,
18
19
  spawn_exterior_zombie,
19
20
  spawn_initial_zombies,
@@ -46,6 +47,7 @@ __all__ = [
46
47
  "place_new_car",
47
48
  "place_fuel_can",
48
49
  "place_flashlights",
50
+ "place_shoes",
49
51
  "place_buddies",
50
52
  "find_interior_spawn_positions",
51
53
  "find_nearby_offscreen_spawn_position",
@@ -12,6 +12,8 @@ from ..entities_constants import (
12
12
  FUEL_CAN_HEIGHT,
13
13
  FUEL_CAN_WIDTH,
14
14
  HUMANOID_RADIUS,
15
+ SHOES_HEIGHT,
16
+ SHOES_WIDTH,
15
17
  SURVIVOR_APPROACH_RADIUS,
16
18
  SURVIVOR_MAX_SAFE_PASSENGERS,
17
19
  )
@@ -58,6 +60,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
58
60
  outside_rects = game_data.layout.outside_rects
59
61
  fuel = game_data.fuel
60
62
  flashlights = game_data.flashlights or []
63
+ shoes_list = game_data.shoes or []
61
64
  camera = game_data.camera
62
65
  stage = game_data.stage
63
66
  maintain_waiting_car_supply(game_data)
@@ -70,6 +73,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
70
73
  flashlight_interaction_radius = _interaction_radius(
71
74
  FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT
72
75
  )
76
+ shoes_interaction_radius = _interaction_radius(SHOES_WIDTH, SHOES_HEIGHT)
73
77
 
74
78
  def _player_near_point(point: tuple[float, float], radius: float) -> bool:
75
79
  dx = point[0] - player.x
@@ -118,6 +122,21 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
118
122
  print("Flashlight acquired!")
119
123
  break
120
124
 
125
+ for shoes in list(shoes_list):
126
+ if not shoes.alive():
127
+ continue
128
+ if _player_near_point(shoes.rect.center, shoes_interaction_radius):
129
+ state.shoes_count += 1
130
+ state.hint_expires_at = 0
131
+ state.hint_target_type = None
132
+ shoes.kill()
133
+ try:
134
+ shoes_list.remove(shoes)
135
+ except ValueError:
136
+ pass
137
+ print("Shoes acquired!")
138
+ break
139
+
121
140
  sync_ambient_palette_with_flashlights(game_data)
122
141
 
123
142
  buddies = [
@@ -10,9 +10,12 @@ from ..entities_constants import INTERNAL_WALL_HEALTH, STEEL_BEAM_HEALTH
10
10
  from .constants import OUTER_WALL_HEALTH
11
11
  from ..level_blueprints import choose_blueprint
12
12
  from ..models import GameData
13
+ from ..rng import get_rng
13
14
 
14
15
  __all__ = ["generate_level_from_blueprint"]
15
16
 
17
+ RNG = get_rng()
18
+
16
19
 
17
20
  def _rect_for_cell(x_idx: int, y_idx: int, cell_size: int) -> pygame.Rect:
18
21
  return pygame.Rect(
@@ -94,6 +97,10 @@ def generate_level_from_blueprint(
94
97
  player_cells: list[pygame.Rect] = []
95
98
  car_cells: list[pygame.Rect] = []
96
99
  zombie_cells: list[pygame.Rect] = []
100
+ interior_min_x = 2
101
+ interior_max_x = stage.grid_cols - 3
102
+ interior_min_y = 2
103
+ interior_max_y = stage.grid_rows - 3
97
104
  bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] = {}
98
105
  palette = get_environment_palette(game_data.state.ambient_palette_key)
99
106
 
@@ -214,11 +221,25 @@ def generate_level_from_blueprint(
214
221
  game_data.layout.walkable_cells = walkable_cells
215
222
  game_data.layout.outer_wall_cells = outer_wall_cells
216
223
  game_data.layout.wall_cells = wall_cells
217
- game_data.layout.fall_spawn_cells = _expand_zone_cells(
224
+ fall_spawn_cells = _expand_zone_cells(
218
225
  stage.fall_spawn_zones,
219
226
  grid_cols=stage.grid_cols,
220
227
  grid_rows=stage.grid_rows,
221
228
  )
229
+ floor_ratio = max(0.0, min(1.0, stage.fall_spawn_floor_ratio))
230
+ if floor_ratio > 0.0 and interior_min_x <= interior_max_x:
231
+ for y in range(interior_min_y, interior_max_y + 1):
232
+ for x in range(interior_min_x, interior_max_x + 1):
233
+ if RNG.random() < floor_ratio:
234
+ fall_spawn_cells.add((x, y))
235
+ if not fall_spawn_cells:
236
+ fall_spawn_cells.add(
237
+ (
238
+ RNG.randint(interior_min_x, interior_max_x),
239
+ RNG.randint(interior_min_y, interior_max_y),
240
+ )
241
+ )
242
+ game_data.layout.fall_spawn_cells = fall_spawn_cells
222
243
  game_data.layout.bevel_corners = bevel_corners
223
244
 
224
245
  return {
@@ -17,6 +17,10 @@ from ..entities_constants import (
17
17
  ZOMBIE_SEPARATION_DISTANCE,
18
18
  ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
19
19
  )
20
+ from ..gameplay_constants import (
21
+ SHOES_SPEED_MULTIPLIER_ONE,
22
+ SHOES_SPEED_MULTIPLIER_TWO,
23
+ )
20
24
  from ..models import GameData
21
25
  from ..world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
22
26
  from .constants import MAX_ZOMBIES
@@ -29,6 +33,7 @@ def process_player_input(
29
33
  keys: Sequence[bool],
30
34
  player: Player,
31
35
  car: Car | None,
36
+ shoes_count: int = 0,
32
37
  pad_input: tuple[float, float] = (0.0, 0.0),
33
38
  ) -> tuple[float, float, float, float]:
34
39
  """Process keyboard input and return movement deltas."""
@@ -55,7 +60,7 @@ def process_player_input(
55
60
  (dy_input / move_len) * target_speed,
56
61
  )
57
62
  elif not player.in_car:
58
- target_speed = PLAYER_SPEED
63
+ target_speed = PLAYER_SPEED * _shoes_speed_multiplier(shoes_count)
59
64
  move_len = math.hypot(dx_input, dy_input)
60
65
  if move_len > 0:
61
66
  player_dx, player_dy = (
@@ -66,6 +71,15 @@ def process_player_input(
66
71
  return player_dx, player_dy, car_dx, car_dy
67
72
 
68
73
 
74
+ def _shoes_speed_multiplier(shoes_count: int) -> float:
75
+ count = max(0, int(shoes_count))
76
+ if count >= 2:
77
+ return SHOES_SPEED_MULTIPLIER_TWO
78
+ if count == 1:
79
+ return SHOES_SPEED_MULTIPLIER_ONE
80
+ return 1.0
81
+
82
+
69
83
  def update_entities(
70
84
  game_data: GameData,
71
85
  player_dx: float,
@@ -9,6 +9,7 @@ from ..entities import (
9
9
  Flashlight,
10
10
  FuelCan,
11
11
  Player,
12
+ Shoes,
12
13
  Survivor,
13
14
  Zombie,
14
15
  random_position_outside_building,
@@ -21,10 +22,17 @@ from ..entities_constants import (
21
22
  ZOMBIE_AGING_DURATION_FRAMES,
22
23
  ZOMBIE_SPEED,
23
24
  )
24
- from ..gameplay_constants import DEFAULT_FLASHLIGHT_SPAWN_COUNT
25
+ from ..gameplay_constants import (
26
+ DEFAULT_FLASHLIGHT_SPAWN_COUNT,
27
+ DEFAULT_SHOES_SPAWN_COUNT,
28
+ )
25
29
  from ..level_constants import DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TILE_SIZE
26
30
  from ..models import DustRing, FallingZombie, GameData, Stage
27
- from ..render_constants import FLASHLIGHT_FOG_SCALE_STEP, FOG_RADIUS_SCALE
31
+ from ..render_constants import (
32
+ FLASHLIGHT_FOG_SCALE_ONE,
33
+ FLASHLIGHT_FOG_SCALE_TWO,
34
+ FOG_RADIUS_SCALE,
35
+ )
28
36
  from ..rng import get_rng
29
37
  from .constants import (
30
38
  MAX_ZOMBIES,
@@ -46,6 +54,7 @@ __all__ = [
46
54
  "place_new_car",
47
55
  "place_fuel_can",
48
56
  "place_flashlights",
57
+ "place_shoes",
49
58
  "place_buddies",
50
59
  "spawn_survivors",
51
60
  "setup_player_and_cars",
@@ -94,7 +103,12 @@ def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
94
103
 
95
104
  def _fov_radius_for_flashlights(flashlight_count: int) -> float:
96
105
  count = max(0, int(flashlight_count))
97
- scale = FOG_RADIUS_SCALE + max(0.0, FLASHLIGHT_FOG_SCALE_STEP) * count
106
+ if count <= 0:
107
+ scale = FOG_RADIUS_SCALE
108
+ elif count == 1:
109
+ scale = FLASHLIGHT_FOG_SCALE_ONE
110
+ else:
111
+ scale = FLASHLIGHT_FOG_SCALE_TWO
98
112
  return FOV_RADIUS * scale
99
113
 
100
114
 
@@ -301,6 +315,7 @@ def place_fuel_can(
301
315
  player: Player,
302
316
  *,
303
317
  cars: Sequence[Car] | None = None,
318
+ reserved_centers: set[tuple[int, int]] | None = None,
304
319
  count: int = 1,
305
320
  ) -> FuelCan | None:
306
321
  """Pick a spawn spot for the fuel can away from the player (and car if given)."""
@@ -314,6 +329,8 @@ def place_fuel_can(
314
329
 
315
330
  for _ in range(200):
316
331
  cell = RNG.choice(walkable_cells)
332
+ if reserved_centers and cell.center in reserved_centers:
333
+ continue
317
334
  dx = cell.centerx - player.x
318
335
  dy = cell.centery - player.y
319
336
  if dx * dx + dy * dy < min_player_dist_sq:
@@ -339,6 +356,7 @@ def _place_flashlight(
339
356
  player: Player,
340
357
  *,
341
358
  cars: Sequence[Car] | None = None,
359
+ reserved_centers: set[tuple[int, int]] | None = None,
342
360
  ) -> Flashlight | None:
343
361
  """Pick a spawn spot for the flashlight away from the player (and car if given)."""
344
362
  if not walkable_cells:
@@ -351,6 +369,8 @@ def _place_flashlight(
351
369
 
352
370
  for _ in range(200):
353
371
  cell = RNG.choice(walkable_cells)
372
+ if reserved_centers and cell.center in reserved_centers:
373
+ continue
354
374
  dx = cell.centerx - player.x
355
375
  dy = cell.centery - player.y
356
376
  if dx * dx + dy * dy < min_player_dist_sq:
@@ -374,6 +394,7 @@ def place_flashlights(
374
394
  player: Player,
375
395
  *,
376
396
  cars: Sequence[Car] | None = None,
397
+ reserved_centers: set[tuple[int, int]] | None = None,
377
398
  count: int = DEFAULT_FLASHLIGHT_SPAWN_COUNT,
378
399
  ) -> list[Flashlight]:
379
400
  """Spawn multiple flashlights using the single-place helper to spread them out."""
@@ -382,7 +403,9 @@ def place_flashlights(
382
403
  max_attempts = max(200, count * 80)
383
404
  while len(placed) < count and attempts < max_attempts:
384
405
  attempts += 1
385
- fl = _place_flashlight(walkable_cells, player, cars=cars)
406
+ fl = _place_flashlight(
407
+ walkable_cells, player, cars=cars, reserved_centers=reserved_centers
408
+ )
386
409
  if not fl:
387
410
  break
388
411
  # Avoid clustering too tightly
@@ -397,6 +420,74 @@ def place_flashlights(
397
420
  return placed
398
421
 
399
422
 
423
+ def _place_shoes(
424
+ walkable_cells: list[pygame.Rect],
425
+ player: Player,
426
+ *,
427
+ cars: Sequence[Car] | None = None,
428
+ reserved_centers: set[tuple[int, int]] | None = None,
429
+ ) -> Shoes | None:
430
+ """Pick a spawn spot for the shoes away from the player (and car if given)."""
431
+ if not walkable_cells:
432
+ return None
433
+
434
+ min_player_dist = 240
435
+ min_car_dist = 200
436
+ min_player_dist_sq = min_player_dist * min_player_dist
437
+ min_car_dist_sq = min_car_dist * min_car_dist
438
+
439
+ for _ in range(200):
440
+ cell = RNG.choice(walkable_cells)
441
+ if reserved_centers and cell.center in reserved_centers:
442
+ continue
443
+ dx = cell.centerx - player.x
444
+ dy = cell.centery - player.y
445
+ if dx * dx + dy * dy < min_player_dist_sq:
446
+ continue
447
+ if cars:
448
+ if any(
449
+ (cell.centerx - parked.rect.centerx) ** 2
450
+ + (cell.centery - parked.rect.centery) ** 2
451
+ < min_car_dist_sq
452
+ for parked in cars
453
+ ):
454
+ continue
455
+ return Shoes(cell.centerx, cell.centery)
456
+
457
+ cell = RNG.choice(walkable_cells)
458
+ return Shoes(cell.centerx, cell.centery)
459
+
460
+
461
+ def place_shoes(
462
+ walkable_cells: list[pygame.Rect],
463
+ player: Player,
464
+ *,
465
+ cars: Sequence[Car] | None = None,
466
+ reserved_centers: set[tuple[int, int]] | None = None,
467
+ count: int = DEFAULT_SHOES_SPAWN_COUNT,
468
+ ) -> list[Shoes]:
469
+ """Spawn multiple shoes using the single-place helper to spread them out."""
470
+ placed: list[Shoes] = []
471
+ attempts = 0
472
+ max_attempts = max(200, count * 80)
473
+ while len(placed) < count and attempts < max_attempts:
474
+ attempts += 1
475
+ shoes = _place_shoes(
476
+ walkable_cells, player, cars=cars, reserved_centers=reserved_centers
477
+ )
478
+ if not shoes:
479
+ break
480
+ if any(
481
+ (other.rect.centerx - shoes.rect.centerx) ** 2
482
+ + (other.rect.centery - shoes.rect.centery) ** 2
483
+ < 120 * 120
484
+ for other in placed
485
+ ):
486
+ continue
487
+ placed.append(shoes)
488
+ return placed
489
+
490
+
400
491
  def place_buddies(
401
492
  walkable_cells: list[pygame.Rect],
402
493
  player: Player,
@@ -31,6 +31,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
31
31
  elapsed_play_ms=0,
32
32
  has_fuel=starts_with_fuel,
33
33
  flashlight_count=initial_flashlights,
34
+ shoes_count=0,
34
35
  ambient_palette_key=initial_palette_key,
35
36
  hint_expires_at=0,
36
37
  hint_target_type=None,
@@ -100,6 +101,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
100
101
  level_height=level_height,
101
102
  fuel=None,
102
103
  flashlights=[],
104
+ shoes=[],
103
105
  )
104
106
 
105
107
 
@@ -13,6 +13,11 @@ SURVIVOR_SPAWN_RATE = 0.07
13
13
  # --- Flashlight settings ---
14
14
  DEFAULT_FLASHLIGHT_SPAWN_COUNT = 2
15
15
 
16
+ # --- Shoes settings ---
17
+ DEFAULT_SHOES_SPAWN_COUNT = 0
18
+ SHOES_SPEED_MULTIPLIER_ONE = 1.176
19
+ SHOES_SPEED_MULTIPLIER_TWO = 1.25
20
+
16
21
  # --- Zombie settings ---
17
22
  ZOMBIE_SPAWN_DELAY_MS = 4000
18
23
 
@@ -25,6 +30,9 @@ __all__ = [
25
30
  "SURVIVAL_FAKE_CLOCK_RATIO",
26
31
  "SURVIVOR_SPAWN_RATE",
27
32
  "DEFAULT_FLASHLIGHT_SPAWN_COUNT",
33
+ "DEFAULT_SHOES_SPAWN_COUNT",
34
+ "SHOES_SPEED_MULTIPLIER_ONE",
35
+ "SHOES_SPEED_MULTIPLIER_TWO",
28
36
  "ZOMBIE_SPAWN_DELAY_MS",
29
37
  "CAR_HINT_DELAY_MS_DEFAULT",
30
38
  ]
@@ -84,11 +84,17 @@ def _place_exits(grid: list[list[str]], exits_per_side: int) -> None:
84
84
  grid[y][x] = "E"
85
85
 
86
86
 
87
- def _place_walls_default(grid: list[list[str]]) -> None:
87
+ def _place_walls_default(
88
+ grid: list[list[str]],
89
+ *,
90
+ forbidden_cells: set[tuple[int, int]] | None = None,
91
+ ) -> None:
88
92
  cols, rows = len(grid[0]), len(grid)
89
93
  rng = RNG.randint
90
- # Avoid placing walls adjacent to exits: collect forbidden cells (exits + neighbors)
94
+ # Avoid placing walls adjacent to exits and on reserved cells.
91
95
  forbidden = _collect_exit_adjacent_cells(grid)
96
+ if forbidden_cells:
97
+ forbidden |= forbidden_cells
92
98
 
93
99
  for _ in range(NUM_WALL_LINES):
94
100
  length = rng(WALL_MIN_LEN, WALL_MAX_LEN)
@@ -111,12 +117,20 @@ def _place_walls_default(grid: list[list[str]]) -> None:
111
117
  grid[y + i][x] = "1"
112
118
 
113
119
 
114
- def _place_walls_empty(grid: list[list[str]]) -> None:
120
+ def _place_walls_empty(
121
+ grid: list[list[str]],
122
+ *,
123
+ forbidden_cells: set[tuple[int, int]] | None = None,
124
+ ) -> None:
115
125
  """Place no internal walls (open floor plan)."""
116
- pass
126
+ _ = (grid, forbidden_cells)
117
127
 
118
128
 
119
- def _place_walls_grid_wire(grid: list[list[str]]) -> None:
129
+ def _place_walls_grid_wire(
130
+ grid: list[list[str]],
131
+ *,
132
+ forbidden_cells: set[tuple[int, int]] | None = None,
133
+ ) -> None:
120
134
  """
121
135
  Place walls using a 2-pass approach with independent layers.
122
136
  Vertical and horizontal walls are generated on separate grids to ensure
@@ -127,6 +141,8 @@ def _place_walls_grid_wire(grid: list[list[str]]) -> None:
127
141
  cols, rows = len(grid[0]), len(grid)
128
142
  rng = RNG.randint
129
143
  forbidden = _collect_exit_adjacent_cells(grid)
144
+ if forbidden_cells:
145
+ forbidden |= forbidden_cells
130
146
 
131
147
  # Temporary grids for independent generation
132
148
  # They only track the internal walls ("1").
@@ -202,10 +218,16 @@ def _place_walls_grid_wire(grid: list[list[str]]) -> None:
202
218
  grid[y][x] = "1"
203
219
 
204
220
 
205
- def _place_walls_sparse(grid: list[list[str]]) -> None:
221
+ def _place_walls_sparse(
222
+ grid: list[list[str]],
223
+ *,
224
+ forbidden_cells: set[tuple[int, int]] | None = None,
225
+ ) -> None:
206
226
  """Place isolated wall tiles at a low density, avoiding adjacency."""
207
227
  cols, rows = len(grid[0]), len(grid)
208
228
  forbidden = _collect_exit_adjacent_cells(grid)
229
+ if forbidden_cells:
230
+ forbidden |= forbidden_cells
209
231
  for y in range(2, rows - 2):
210
232
  for x in range(2, cols - 2):
211
233
  if (x, y) in forbidden:
@@ -237,11 +259,16 @@ WALL_ALGORITHMS = {
237
259
 
238
260
 
239
261
  def _place_steel_beams(
240
- grid: list[list[str]], *, chance: float = STEEL_BEAM_CHANCE
262
+ grid: list[list[str]],
263
+ *,
264
+ chance: float = STEEL_BEAM_CHANCE,
265
+ forbidden_cells: set[tuple[int, int]] | None = None,
241
266
  ) -> set[tuple[int, int]]:
242
267
  """Pick individual cells for steel beams, avoiding exits and their neighbors."""
243
268
  cols, rows = len(grid[0]), len(grid)
244
269
  forbidden = _collect_exit_adjacent_cells(grid)
270
+ if forbidden_cells:
271
+ forbidden |= forbidden_cells
245
272
  beams: set[tuple[int, int]] = set()
246
273
  for y in range(2, rows - 2):
247
274
  for x in range(2, cols - 2):
@@ -257,7 +284,6 @@ def _place_steel_beams(
257
284
  def _pick_empty_cell(
258
285
  grid: list[list[str]],
259
286
  margin: int,
260
- forbidden: set[tuple[int, int]],
261
287
  ) -> tuple[int, int]:
262
288
  cols, rows = len(grid[0]), len(grid)
263
289
  attempts = 0
@@ -265,12 +291,12 @@ def _pick_empty_cell(
265
291
  attempts += 1
266
292
  x = RNG.randint(margin, cols - margin - 1)
267
293
  y = RNG.randint(margin, rows - margin - 1)
268
- if grid[y][x] == "." and (x, y) not in forbidden:
294
+ if grid[y][x] == ".":
269
295
  return x, y
270
296
  # Fallback: scan for any acceptable cell
271
297
  for y in range(margin, rows - margin):
272
298
  for x in range(margin, cols - margin):
273
- if grid[y][x] == "." and (x, y) not in forbidden:
299
+ if grid[y][x] == ".":
274
300
  return x, y
275
301
  return cols // 2, rows // 2
276
302
 
@@ -281,7 +307,20 @@ def _generate_random_blueprint(
281
307
  grid = _init_grid(cols, rows)
282
308
  _place_exits(grid, EXITS_PER_SIDE)
283
309
 
284
- # Select and run the wall placement algorithm
310
+ # Spawns: player, car, zombies
311
+ reserved_cells: set[tuple[int, int]] = set()
312
+ px, py = _pick_empty_cell(grid, SPAWN_MARGIN)
313
+ grid[py][px] = "P"
314
+ reserved_cells.add((px, py))
315
+ cx, cy = _pick_empty_cell(grid, SPAWN_MARGIN)
316
+ grid[cy][cx] = "C"
317
+ reserved_cells.add((cx, cy))
318
+ for _ in range(SPAWN_ZOMBIES):
319
+ zx, zy = _pick_empty_cell(grid, SPAWN_MARGIN)
320
+ grid[zy][zx] = "Z"
321
+ reserved_cells.add((zx, zy))
322
+
323
+ # Select and run the wall placement algorithm (after reserving spawns)
285
324
  if wall_algo not in WALL_ALGORITHMS:
286
325
  print(
287
326
  f"WARNING: Unknown wall algorithm '{wall_algo}'. Falling back to 'default'."
@@ -289,18 +328,11 @@ def _generate_random_blueprint(
289
328
  wall_algo = "default"
290
329
 
291
330
  algo_func = WALL_ALGORITHMS[wall_algo]
292
- algo_func(grid)
293
-
294
- steel_beams = _place_steel_beams(grid, chance=steel_chance)
331
+ algo_func(grid, forbidden_cells=reserved_cells)
295
332
 
296
- # Spawns: player, car, zombies
297
- px, py = _pick_empty_cell(grid, SPAWN_MARGIN, forbidden=steel_beams)
298
- grid[py][px] = "P"
299
- cx, cy = _pick_empty_cell(grid, SPAWN_MARGIN, forbidden=steel_beams)
300
- grid[cy][cx] = "C"
301
- for _ in range(SPAWN_ZOMBIES):
302
- zx, zy = _pick_empty_cell(grid, SPAWN_MARGIN, forbidden=steel_beams)
303
- grid[zy][zx] = "Z"
333
+ steel_beams = _place_steel_beams(
334
+ grid, chance=steel_chance, forbidden_cells=reserved_cells
335
+ )
304
336
 
305
337
  blueprint_rows = ["".join(row) for row in grid]
306
338
  return {"grid": blueprint_rows, "steel_cells": steel_beams}
@@ -118,7 +118,15 @@
118
118
  },
119
119
  "stage13": {
120
120
  "name": "#13 Rescue Buddy 3",
121
- "description": "Rescue your buddy. Zombies may fall from above."
121
+ "description": "Rescue your buddy. Falling zombies."
122
+ },
123
+ "stage14": {
124
+ "name": "#14 Falling Factory",
125
+ "description": "A factory with collapsed upper floors. Break through obstacles."
126
+ },
127
+ "stage15": {
128
+ "name": "#15 The Divide",
129
+ "description": "A central hazard splits the building. Cross with care."
122
130
  }
123
131
  },
124
132
  "status": {