zombie-escape 1.12.3__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.
@@ -16,13 +16,13 @@ from ..entities_constants import (
16
16
  HUMANOID_WALL_BUMP_FRAMES,
17
17
  PLAYER_SPEED,
18
18
  ZOMBIE_SEPARATION_DISTANCE,
19
- ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
19
+ ZOMBIE_WALL_HUG_SENSOR_DISTANCE,
20
20
  )
21
21
  from ..gameplay_constants import (
22
22
  SHOES_SPEED_MULTIPLIER_ONE,
23
23
  SHOES_SPEED_MULTIPLIER_TWO,
24
24
  )
25
- from ..models import GameData
25
+ from ..models import FallingZombie, GameData
26
26
  from ..world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
27
27
  from .constants import MAX_ZOMBIES
28
28
  from .spawn import spawn_weighted_zombie, update_falling_zombies
@@ -84,6 +84,28 @@ def _shoes_speed_multiplier(shoes_count: int) -> float:
84
84
  return 1.0
85
85
 
86
86
 
87
+ def _handle_pitfall_detection(
88
+ x: float,
89
+ y: float,
90
+ cell_size: int,
91
+ pitfall_cells: set[tuple[int, int]],
92
+ pull_distance: float,
93
+ ) -> tuple[int, int] | None:
94
+ """Check if position is in pitfall and return pulled target coordinates if so."""
95
+ cx, cy = int(x // cell_size), int(y // cell_size)
96
+ if (cx, cy) not in pitfall_cells:
97
+ return None
98
+
99
+ cell_center_x = (cx * cell_size) + (cell_size // 2)
100
+ cell_center_y = (cy * cell_size) + (cell_size // 2)
101
+ dx, dy = cell_center_x - x, cell_center_y - y
102
+ dist = math.hypot(dx, dy)
103
+ if dist > 0:
104
+ move_factor = min(1.0, pull_distance / dist)
105
+ return int(x + dx * move_factor), int(y + dy * move_factor)
106
+ return int(x), int(y)
107
+
108
+
87
109
  def update_entities(
88
110
  game_data: GameData,
89
111
  player_dx: float,
@@ -105,6 +127,8 @@ def update_entities(
105
127
  stage = game_data.stage
106
128
  active_car = car if car and car.alive() else None
107
129
  wall_cells = game_data.layout.wall_cells
130
+ pitfall_cells = game_data.layout.pitfall_cells
131
+ walkable_cells = game_data.layout.walkable_cells
108
132
  bevel_corners = game_data.layout.bevel_corners
109
133
 
110
134
  all_walls = list(wall_group) if wall_index is None else None
@@ -135,7 +159,14 @@ def update_entities(
135
159
  grid_rows=stage.grid_rows,
136
160
  )
137
161
  car_walls = _walls_near((active_car.x, active_car.y), 150.0)
138
- active_car.move(car_dx, car_dy, car_walls, walls_nearby=wall_index is not None)
162
+ active_car.move(
163
+ car_dx,
164
+ car_dy,
165
+ car_walls,
166
+ walls_nearby=wall_index is not None,
167
+ cell_size=game_data.cell_size,
168
+ pitfall_cells=pitfall_cells,
169
+ )
139
170
  player.rect.center = active_car.rect.center
140
171
  player.x, player.y = active_car.x, active_car.y
141
172
  elif not player.in_car:
@@ -161,6 +192,8 @@ def update_entities(
161
192
  cell_size=game_data.cell_size,
162
193
  level_width=game_data.level_width,
163
194
  level_height=game_data.level_height,
195
+ pitfall_cells=pitfall_cells,
196
+ walkable_cells=walkable_cells,
164
197
  )
165
198
  else:
166
199
  # Player flagged as in-car but car is gone; drop them back to foot control
@@ -276,7 +309,7 @@ def update_entities(
276
309
  + (pos[1] - zombie.y) ** 2,
277
310
  )
278
311
  nearby_candidates = _nearby_zombies(idx)
279
- zombie_search_radius = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius + 120
312
+ zombie_search_radius = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius + 120
280
313
  nearby_walls = _walls_near((zombie.x, zombie.y), zombie_search_radius)
281
314
  zombie.update(
282
315
  target,
@@ -290,5 +323,30 @@ def update_entities(
290
323
  level_height=game_data.level_height,
291
324
  outer_wall_cells=game_data.layout.outer_wall_cells,
292
325
  wall_cells=game_data.layout.wall_cells,
326
+ pitfall_cells=game_data.layout.pitfall_cells,
293
327
  bevel_corners=game_data.layout.bevel_corners,
294
328
  )
329
+
330
+ # Check zombie pitfall
331
+ pull_dist = zombie.radius * 0.5
332
+ pitfall_target = _handle_pitfall_detection(
333
+ zombie.x,
334
+ zombie.y,
335
+ game_data.cell_size,
336
+ pitfall_cells,
337
+ pull_dist,
338
+ )
339
+ if pitfall_target is not None:
340
+ zombie.kill()
341
+ fall = FallingZombie(
342
+ start_pos=(int(zombie.x), int(zombie.y)),
343
+ target_pos=pitfall_target,
344
+ started_at_ms=pygame.time.get_ticks(),
345
+ pre_fx_ms=0,
346
+ fall_duration_ms=500,
347
+ dust_duration_ms=0,
348
+ tracker=zombie.tracker,
349
+ wall_hugging=zombie.wall_hugging,
350
+ mode="pitfall",
351
+ )
352
+ game_data.state.falling_zombies.append(fall)
@@ -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,
@@ -82,22 +85,22 @@ def _car_appearance_for_stage(stage: Stage | None) -> str:
82
85
  def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
83
86
  normal_ratio = 1.0
84
87
  tracker_ratio = 0.0
85
- wall_follower_ratio = 0.0
88
+ wall_hugging_ratio = 0.0
86
89
  if stage is not None:
87
90
  normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
88
91
  tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
89
- wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
90
- 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:
91
94
  normal_ratio = 1.0
92
95
  tracker_ratio = 0.0
93
- wall_follower_ratio = 0.0
96
+ wall_hugging_ratio = 0.0
94
97
  if (
95
98
  normal_ratio == 1.0
96
- and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
97
- 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
98
101
  ):
99
- normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
100
- 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
101
104
  if total_ratio <= 0:
102
105
  return False, False
103
106
  pick = RNG.random() * total_ratio
@@ -210,7 +213,7 @@ def _schedule_falling_zombie(
210
213
  if len(zombie_group) + len(state.falling_zombies) >= MAX_ZOMBIES:
211
214
  return "blocked"
212
215
  min_distance = game_data.stage.tile_size * 0.5
213
- tracker, wall_follower = _pick_zombie_variant(game_data.stage)
216
+ tracker, wall_hugging = _pick_zombie_variant(game_data.stage)
214
217
 
215
218
  def _candidate_clear(pos: tuple[int, int]) -> bool:
216
219
  candidate = _create_zombie(
@@ -218,7 +221,7 @@ def _schedule_falling_zombie(
218
221
  start_pos=pos,
219
222
  stage=game_data.stage,
220
223
  tracker=tracker,
221
- wall_follower=wall_follower,
224
+ wall_hugging=wall_hugging,
222
225
  )
223
226
  return _is_spawn_position_clear(game_data, candidate)
224
227
 
@@ -231,17 +234,17 @@ def _schedule_falling_zombie(
231
234
  if allow_carry:
232
235
  state.falling_spawn_carry += 1
233
236
  return "no_position"
234
- start_offset = game_data.stage.tile_size * 0.7
235
- 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]))
236
239
  fall = FallingZombie(
237
240
  start_pos=start_pos,
238
241
  target_pos=(int(spawn_pos[0]), int(spawn_pos[1])),
239
242
  started_at_ms=pygame.time.get_ticks(),
240
- pre_fx_ms=350,
241
- fall_duration_ms=450,
242
- 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,
243
246
  tracker=tracker,
244
- wall_follower=wall_follower,
247
+ wall_hugging=wall_hugging,
245
248
  )
246
249
  state.falling_zombies.append(fall)
247
250
  return "scheduled"
@@ -254,7 +257,7 @@ def _create_zombie(
254
257
  hint_pos: tuple[float, float] | None = None,
255
258
  stage: Stage | None = None,
256
259
  tracker: bool | None = None,
257
- wall_follower: bool | None = None,
260
+ wall_hugging: bool | None = None,
258
261
  ) -> Zombie:
259
262
  """Factory to create zombies with optional fast variants."""
260
263
  fast_conf = config.get("fast_zombies", {})
@@ -271,14 +274,14 @@ def _create_zombie(
271
274
  )
272
275
  else:
273
276
  aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
274
- if tracker is None or wall_follower is None:
275
- 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)
276
279
  if tracker is None:
277
280
  tracker = picked_tracker
278
- if wall_follower is None:
279
- wall_follower = picked_wall_follower
281
+ if wall_hugging is None:
282
+ wall_hugging = picked_wall_hugging
280
283
  if tracker:
281
- wall_follower = False
284
+ wall_hugging = False
282
285
  if tracker:
283
286
  ratio = (
284
287
  ZOMBIE_TRACKER_AGING_DURATION_FRAMES / ZOMBIE_AGING_DURATION_FRAMES
@@ -312,7 +315,7 @@ def _create_zombie(
312
315
  y=float(start_pos[1]),
313
316
  speed=base_speed,
314
317
  tracker=tracker,
315
- wall_follower=wall_follower,
318
+ wall_hugging=wall_hugging,
316
319
  aging_duration_frames=aging_duration_frames,
317
320
  )
318
321
 
@@ -656,7 +659,8 @@ def setup_player_and_cars(
656
659
  player_pos = _pick_center(layout_data["player_cells"] or walkable_cells)
657
660
  player = Player(*player_pos)
658
661
 
659
- 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)
660
664
  waiting_cars: list[Car] = []
661
665
  car_appearance = _car_appearance_for_stage(game_data.stage)
662
666
 
@@ -722,7 +726,7 @@ def spawn_initial_zombies(
722
726
  start_pos=candidate_center,
723
727
  stage=game_data.stage,
724
728
  tracker=True,
725
- wall_follower=False,
729
+ wall_hugging=False,
726
730
  )
727
731
  if not spritecollideany_walls(tentative, wall_group):
728
732
  zombie_group.add(tentative)
@@ -741,13 +745,13 @@ def spawn_initial_zombies(
741
745
  )
742
746
 
743
747
  for pos in positions:
744
- tracker, wall_follower = _pick_zombie_variant(game_data.stage)
748
+ tracker, wall_hugging = _pick_zombie_variant(game_data.stage)
745
749
  tentative = _create_zombie(
746
750
  config,
747
751
  start_pos=pos,
748
752
  stage=game_data.stage,
749
753
  tracker=tracker,
750
- wall_follower=wall_follower,
754
+ wall_hugging=wall_hugging,
751
755
  )
752
756
  if spritecollideany_walls(tentative, wall_group):
753
757
  continue
@@ -763,7 +767,9 @@ def spawn_waiting_car(game_data: GameData) -> Car | None:
763
767
  player = game_data.player
764
768
  if not player:
765
769
  return None
766
- 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
767
773
  if not walkable_cells:
768
774
  return None
769
775
  wall_group = game_data.groups.wall_group
@@ -919,18 +925,19 @@ def update_falling_zombies(game_data: GameData, config: dict[str, Any]) -> None:
919
925
  fall.dust_started = True
920
926
  if now < spawn_at:
921
927
  continue
922
- if len(zombie_group) >= MAX_ZOMBIES:
923
- state.falling_zombies.remove(fall)
924
- continue
925
- candidate = _create_zombie(
926
- config,
927
- start_pos=fall.target_pos,
928
- stage=game_data.stage,
929
- tracker=fall.tracker,
930
- wall_follower=fall.wall_follower,
931
- )
932
- zombie_group.add(candidate)
933
- 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
+
934
941
  state.falling_zombies.remove(fall)
935
942
 
936
943
 
@@ -51,6 +51,7 @@ 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=[],
@@ -88,6 +89,8 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
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
  ),
@@ -44,6 +44,7 @@ def update_survivors(
44
44
  target_rect = car.rect if player.in_car and car and car.alive() else player.rect
45
45
  target_pos = target_rect.center
46
46
  survivors = [s for s in survivor_group if s.alive()]
47
+
47
48
  for survivor in survivors:
48
49
  survivor.update_behavior(
49
50
  target_pos,
@@ -51,6 +52,8 @@ def update_survivors(
51
52
  wall_index=wall_index,
52
53
  cell_size=game_data.cell_size,
53
54
  wall_cells=game_data.layout.wall_cells,
55
+ pitfall_cells=game_data.layout.pitfall_cells,
56
+ walkable_cells=game_data.layout.walkable_cells,
54
57
  bevel_corners=game_data.layout.bevel_corners,
55
58
  grid_cols=game_data.stage.grid_cols,
56
59
  grid_rows=game_data.stage.grid_rows,
@@ -294,7 +297,7 @@ def handle_survivor_zombie_collisions(
294
297
  start_pos=survivor.rect.center,
295
298
  stage=game_data.stage,
296
299
  tracker=collided_zombie.tracker,
297
- wall_follower=collided_zombie.wall_follower,
300
+ wall_hugging=collided_zombie.wall_hugging,
298
301
  )
299
302
  zombie_group.add(new_zombie)
300
303
  game_data.groups.all_sprites.add(new_zombie, layer=1)
@@ -1,6 +1,8 @@
1
1
  # Blueprint generator for randomized layouts.
2
2
 
3
- from .rng import get_rng
3
+ from collections import deque
4
+
5
+ from .rng import get_rng, seed_rng
4
6
 
5
7
  EXITS_PER_SIDE = 1 # currently fixed to 1 per side (can be tuned)
6
8
  NUM_WALL_LINES = 80 # reduced density (roughly 1/5 of previous 450)
@@ -13,15 +15,94 @@ SPAWN_ZOMBIES = 3
13
15
  RNG = get_rng()
14
16
  STEEL_BEAM_CHANCE = 0.02
15
17
 
16
- # Legend:
17
- # O: outside area (win when car reaches)
18
- # B: outer wall band (solid)
19
- # E: exit tile (opening in outer wall)
20
- # 1: internal wall
21
- # .: empty floor
22
- # P: player spawn candidate
23
- # C: car spawn candidate
24
- # Z: zombie spawn candidate
18
+
19
+ class MapGenerationError(Exception):
20
+ """Raised when a valid map cannot be generated after several attempts."""
21
+
22
+
23
+ def validate_car_connectivity(grid: list[str]) -> set[tuple[int, int]] | None:
24
+ """Check if the Car can reach at least one exit (4-way BFS).
25
+ Returns the set of reachable tiles if valid, otherwise None.
26
+ """
27
+ rows = len(grid)
28
+ cols = len(grid[0])
29
+
30
+ start_pos = None
31
+ passable_tiles = set()
32
+ exit_tiles = set()
33
+
34
+ for y in range(rows):
35
+ for x in range(cols):
36
+ ch = grid[y][x]
37
+ if ch == "C":
38
+ start_pos = (x, y)
39
+ if ch not in ("x", "B"):
40
+ passable_tiles.add((x, y))
41
+ if ch == "E":
42
+ exit_tiles.add((x, y))
43
+
44
+ if start_pos is None:
45
+ # If no car candidate, we can't validate car pathing.
46
+ return passable_tiles
47
+
48
+ reachable = {start_pos}
49
+ queue = deque([start_pos])
50
+ while queue:
51
+ x, y = queue.popleft()
52
+ for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
53
+ nx, ny = x + dx, y + dy
54
+ if (nx, ny) in passable_tiles and (nx, ny) not in reachable:
55
+ reachable.add((nx, ny))
56
+ queue.append((nx, ny))
57
+
58
+ # Car must reach at least one exit
59
+ if exit_tiles and not any(e in reachable for e in exit_tiles):
60
+ return None
61
+ return reachable
62
+
63
+
64
+ def validate_humanoid_connectivity(grid: list[str]) -> bool:
65
+ """Check if all floor tiles are reachable by Humans (8-way BFS with jumps)."""
66
+ rows = len(grid)
67
+ cols = len(grid[0])
68
+
69
+ start_pos = None
70
+ passable_tiles = set()
71
+
72
+ for y in range(rows):
73
+ for x in range(cols):
74
+ ch = grid[y][x]
75
+ if ch == "P":
76
+ start_pos = (x, y)
77
+ if ch not in ("x", "B"):
78
+ passable_tiles.add((x, y))
79
+
80
+ if start_pos is None:
81
+ return False
82
+
83
+ reachable = {start_pos}
84
+ queue = deque([start_pos])
85
+ while queue:
86
+ x, y = queue.popleft()
87
+ for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (1, -1), (-1, 1), (-1, -1)):
88
+ nx, ny = x + dx, y + dy
89
+ if (nx, ny) in passable_tiles and (nx, ny) not in reachable:
90
+ reachable.add((nx, ny))
91
+ queue.append((nx, ny))
92
+
93
+ return len(passable_tiles) == len(reachable)
94
+
95
+
96
+ def validate_connectivity(grid: list[str]) -> set[tuple[int, int]] | None:
97
+ """Validate both car and humanoid movement conditions.
98
+ Returns car reachable tiles if both pass, otherwise None.
99
+ """
100
+ car_reachable = validate_car_connectivity(grid)
101
+ if car_reachable is None:
102
+ return None
103
+ if not validate_humanoid_connectivity(grid):
104
+ return None
105
+ return car_reachable
25
106
 
26
107
 
27
108
  def _collect_exit_adjacent_cells(grid: list[list[str]]) -> set[tuple[int, int]]:
@@ -311,6 +392,30 @@ def _place_steel_beams(
311
392
  return beams
312
393
 
313
394
 
395
+ def _place_pitfalls(
396
+ grid: list[list[str]],
397
+ *,
398
+ density: float,
399
+ forbidden_cells: set[tuple[int, int]] | None = None,
400
+ ) -> None:
401
+ """Replace empty floor tiles with pitfalls based on density."""
402
+ if density <= 0.0:
403
+ return
404
+ cols, rows = len(grid[0]), len(grid)
405
+ forbidden = _collect_exit_adjacent_cells(grid)
406
+ if forbidden_cells:
407
+ forbidden |= forbidden_cells
408
+
409
+ for y in range(1, rows - 1):
410
+ for x in range(1, cols - 1):
411
+ if (x, y) in forbidden:
412
+ continue
413
+ if grid[y][x] != ".":
414
+ continue
415
+ if RNG.random() < density:
416
+ grid[y][x] = "x"
417
+
418
+
314
419
  def _pick_empty_cell(
315
420
  grid: list[list[str]],
316
421
  margin: int,
@@ -332,7 +437,12 @@ def _pick_empty_cell(
332
437
 
333
438
 
334
439
  def _generate_random_blueprint(
335
- steel_chance: float, *, cols: int, rows: int, wall_algo: str = "default"
440
+ steel_chance: float,
441
+ *,
442
+ cols: int,
443
+ rows: int,
444
+ wall_algo: str = "default",
445
+ pitfall_density: float = 0.0,
336
446
  ) -> dict:
337
447
  grid = _init_grid(cols, rows)
338
448
  _place_exits(grid, EXITS_PER_SIDE)
@@ -350,6 +460,21 @@ def _generate_random_blueprint(
350
460
  grid[zy][zx] = "Z"
351
461
  reserved_cells.add((zx, zy))
352
462
 
463
+ # Items
464
+ fx, fy = _pick_empty_cell(grid, SPAWN_MARGIN)
465
+ grid[fy][fx] = "f"
466
+ reserved_cells.add((fx, fy))
467
+
468
+ for _ in range(2):
469
+ lx, ly = _pick_empty_cell(grid, SPAWN_MARGIN)
470
+ grid[ly][lx] = "l"
471
+ reserved_cells.add((lx, ly))
472
+
473
+ for _ in range(2):
474
+ sx, sy = _pick_empty_cell(grid, SPAWN_MARGIN)
475
+ grid[sy][sx] = "s"
476
+ reserved_cells.add((sx, sy))
477
+
353
478
  # Select and run the wall placement algorithm (after reserving spawns)
354
479
  sparse_density = SPARSE_WALL_DENSITY
355
480
  original_wall_algo = wall_algo
@@ -407,6 +532,13 @@ def _generate_random_blueprint(
407
532
  )
408
533
  wall_algo = "default"
409
534
 
535
+ # Place pitfalls BEFORE walls so walls avoid them (consistent with spawn reservation)
536
+ _place_pitfalls(
537
+ grid,
538
+ density=pitfall_density,
539
+ forbidden_cells=reserved_cells,
540
+ )
541
+
410
542
  algo_func = WALL_ALGORITHMS[wall_algo]
411
543
  if wall_algo in {"sparse_moore", "sparse_ortho"}:
412
544
  algo_func(grid, density=sparse_density, forbidden_cells=reserved_cells)
@@ -422,7 +554,13 @@ def _generate_random_blueprint(
422
554
 
423
555
 
424
556
  def choose_blueprint(
425
- config: dict, *, cols: int, rows: int, wall_algo: str = "default"
557
+ config: dict,
558
+ *,
559
+ cols: int,
560
+ rows: int,
561
+ wall_algo: str = "default",
562
+ pitfall_density: float = 0.0,
563
+ base_seed: int | None = None,
426
564
  ) -> dict:
427
565
  # Currently only random generation; hook for future variants.
428
566
  steel_conf = config.get("steel_beams", {})
@@ -430,6 +568,22 @@ def choose_blueprint(
430
568
  steel_chance = float(steel_conf.get("chance", STEEL_BEAM_CHANCE))
431
569
  except (TypeError, ValueError):
432
570
  steel_chance = STEEL_BEAM_CHANCE
433
- return _generate_random_blueprint(
434
- steel_chance=steel_chance, cols=cols, rows=rows, wall_algo=wall_algo
435
- )
571
+
572
+ for attempt in range(20):
573
+ if base_seed is not None:
574
+ seed_rng(base_seed + attempt)
575
+
576
+ blueprint = _generate_random_blueprint(
577
+ steel_chance=steel_chance,
578
+ cols=cols,
579
+ rows=rows,
580
+ wall_algo=wall_algo,
581
+ pitfall_density=pitfall_density,
582
+ )
583
+
584
+ car_reachable = validate_connectivity(blueprint["grid"])
585
+ if car_reachable is not None:
586
+ blueprint["car_reachable_cells"] = car_reachable
587
+ return blueprint
588
+
589
+ raise MapGenerationError("Connectivity validation failed after 20 attempts")
@@ -16,7 +16,7 @@
16
16
  "settings": "Settings",
17
17
  "quit": "Quit",
18
18
  "locked_suffix": "[Locked]",
19
- "window_hint": "Resize window: [ to shrink, ] to enlarge.\nF: toggle fullscreen.\n(Title/Settings only)",
19
+ "window_hint": "Resize window: [ to shrink, ] to enlarge.\nF: toggle fullscreen.",
20
20
  "seed_label": "Seed: %{value}",
21
21
  "seed_hint": "Type 0-9 to set a custom seed, Backspace clears",
22
22
  "seed_empty": "(auto)",
@@ -25,6 +25,7 @@
25
25
  "resources": "Resources"
26
26
  },
27
27
  "readme": "Open README",
28
+ "readme_stage6": "Open Stage 6+ Guide",
28
29
  "hints": {
29
30
  "navigate": "Up/Down: choose an option",
30
31
  "page_switch": "Left/Right: switch stage groups",
@@ -33,7 +34,8 @@
33
34
  "option_help": {
34
35
  "settings": "Open Settings to adjust assists and localization.",
35
36
  "quit": "Close the game.",
36
- "readme": "Open the GitHub README in your browser."
37
+ "readme": "Open the GitHub README in your browser.",
38
+ "readme_stage6": "Open the Stage 6+ guide in your browser."
37
39
  }
38
40
  },
39
41
  "stages": {
@@ -127,6 +129,10 @@
127
129
  "stage15": {
128
130
  "name": "#15 The Divide",
129
131
  "description": "A central hazard splits the building. Cross with care."
132
+ },
133
+ "stage16": {
134
+ "name": "#16 Floor Collapse",
135
+ "description": "Circumnavigate the collapsed floor panels and lure the zombies into the abyss."
130
136
  }
131
137
  },
132
138
  "status": {
@@ -198,10 +204,14 @@
198
204
  "game_over": {
199
205
  "win": "YOU ESCAPED!",
200
206
  "lose": "GAME OVER",
207
+ "fell_into_abyss": "You fell into the abyss...",
201
208
  "prompt": "ESC/SPACE/Select/South: Title · R: Retry",
202
209
  "scream": "AAAAHHH!!",
203
210
  "survivors_summary": "Evacuated: %{count}",
204
211
  "endurance_duration": "Time survived %{time}"
212
+ },
213
+ "errors": {
214
+ "map_generation_failed": "Map generation failed. Please try a different seed or stage."
205
215
  }
206
216
  }
207
217
  }