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
@@ -29,7 +29,9 @@ RNG = get_rng()
29
29
 
30
30
 
31
31
  def update_survivors(
32
- game_data: GameData, wall_index: WallIndex | None = None
32
+ game_data: GameData,
33
+ wall_index: WallIndex | None = None,
34
+ wall_target_cell: tuple[int, int] | None = None,
33
35
  ) -> None:
34
36
  if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
35
37
  return
@@ -42,6 +44,7 @@ def update_survivors(
42
44
  target_rect = car.rect if player.in_car and car and car.alive() else player.rect
43
45
  target_pos = target_rect.center
44
46
  survivors = [s for s in survivor_group if s.alive()]
47
+
45
48
  for survivor in survivors:
46
49
  survivor.update_behavior(
47
50
  target_pos,
@@ -49,11 +52,14 @@ def update_survivors(
49
52
  wall_index=wall_index,
50
53
  cell_size=game_data.cell_size,
51
54
  wall_cells=game_data.layout.wall_cells,
55
+ pitfall_cells=game_data.layout.pitfall_cells,
56
+ walkable_cells=game_data.layout.walkable_cells,
52
57
  bevel_corners=game_data.layout.bevel_corners,
53
58
  grid_cols=game_data.stage.grid_cols,
54
59
  grid_rows=game_data.stage.grid_rows,
55
60
  level_width=game_data.level_width,
56
61
  level_height=game_data.level_height,
62
+ wall_target_cell=wall_target_cell,
57
63
  )
58
64
 
59
65
  # Gently prevent survivors from overlapping the player or each other
@@ -106,6 +112,7 @@ def update_survivors(
106
112
  other.rect.center = (int(other.x), int(other.y))
107
113
 
108
114
 
115
+
109
116
  def calculate_car_speed_for_passengers(
110
117
  passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
111
118
  ) -> float:
@@ -243,6 +250,7 @@ def handle_survivor_zombie_collisions(
243
250
  zombie_xs = [z.rect.centerx for z in zombies]
244
251
  camera = game_data.camera
245
252
  walkable_cells = game_data.layout.walkable_cells
253
+ cell_size = game_data.cell_size
246
254
 
247
255
  for survivor in list(survivor_group):
248
256
  if not survivor.alive():
@@ -275,6 +283,7 @@ def handle_survivor_zombie_collisions(
275
283
  if not rect_visible_on_screen(camera, survivor.rect):
276
284
  spawn_pos = find_nearby_offscreen_spawn_position(
277
285
  walkable_cells,
286
+ cell_size,
278
287
  camera=camera,
279
288
  )
280
289
  survivor.teleport(spawn_pos)
@@ -288,7 +297,7 @@ def handle_survivor_zombie_collisions(
288
297
  start_pos=survivor.rect.center,
289
298
  stage=game_data.stage,
290
299
  tracker=collided_zombie.tracker,
291
- wall_follower=collided_zombie.wall_follower,
300
+ wall_hugging=collided_zombie.wall_hugging,
292
301
  )
293
302
  zombie_group.add(new_zombie)
294
303
  game_data.groups.all_sprites.add(new_zombie, layer=1)
@@ -310,6 +319,7 @@ def respawn_buddies_near_player(game_data: GameData) -> None:
310
319
  wall_group = game_data.groups.wall_group
311
320
  camera = game_data.camera
312
321
  walkable_cells = game_data.layout.walkable_cells
322
+ cell_size = game_data.cell_size
313
323
  offsets = [
314
324
  (BUDDY_RADIUS * 3, 0),
315
325
  (-BUDDY_RADIUS * 3, 0),
@@ -321,6 +331,7 @@ def respawn_buddies_near_player(game_data: GameData) -> None:
321
331
  if walkable_cells:
322
332
  spawn_pos = find_nearby_offscreen_spawn_position(
323
333
  walkable_cells,
334
+ cell_size,
324
335
  camera=camera,
325
336
  )
326
337
  else:
@@ -25,7 +25,8 @@ def rect_visible_on_screen(camera: Camera | None, rect: pygame.Rect) -> bool:
25
25
 
26
26
 
27
27
  def _scatter_positions_on_walkable(
28
- walkable_cells: list[pygame.Rect],
28
+ walkable_cells: list[tuple[int, int]],
29
+ cell_size: int,
29
30
  spawn_rate: float,
30
31
  *,
31
32
  jitter_ratio: float = 0.35,
@@ -35,17 +36,21 @@ def _scatter_positions_on_walkable(
35
36
  return positions
36
37
 
37
38
  clamped_rate = max(0.0, min(1.0, spawn_rate))
38
- for cell in walkable_cells:
39
+ for cell_x, cell_y in walkable_cells:
39
40
  if RNG.random() >= clamped_rate:
40
41
  continue
41
- jitter_x = RNG.uniform(-cell.width * jitter_ratio, cell.width * jitter_ratio)
42
- jitter_y = RNG.uniform(-cell.height * jitter_ratio, cell.height * jitter_ratio)
43
- positions.append((int(cell.centerx + jitter_x), int(cell.centery + jitter_y)))
42
+ jitter_extent = cell_size * jitter_ratio
43
+ jitter_x = RNG.uniform(-jitter_extent, jitter_extent)
44
+ jitter_y = RNG.uniform(-jitter_extent, jitter_extent)
45
+ base_x = (cell_x * cell_size) + (cell_size / 2)
46
+ base_y = (cell_y * cell_size) + (cell_size / 2)
47
+ positions.append((int(base_x + jitter_x), int(base_y + jitter_y)))
44
48
  return positions
45
49
 
46
50
 
47
51
  def find_interior_spawn_positions(
48
- walkable_cells: list[pygame.Rect],
52
+ walkable_cells: list[tuple[int, int]],
53
+ cell_size: int,
49
54
  spawn_rate: float,
50
55
  *,
51
56
  player: Player | None = None,
@@ -53,12 +58,14 @@ def find_interior_spawn_positions(
53
58
  ) -> list[tuple[int, int]]:
54
59
  positions = _scatter_positions_on_walkable(
55
60
  walkable_cells,
61
+ cell_size,
56
62
  spawn_rate,
57
63
  jitter_ratio=0.35,
58
64
  )
59
65
  if not positions and spawn_rate > 0:
60
66
  positions = _scatter_positions_on_walkable(
61
67
  walkable_cells,
68
+ cell_size,
62
69
  spawn_rate * 1.5,
63
70
  jitter_ratio=0.35,
64
71
  )
@@ -78,7 +85,8 @@ def find_interior_spawn_positions(
78
85
 
79
86
 
80
87
  def find_nearby_offscreen_spawn_position(
81
- walkable_cells: list[pygame.Rect],
88
+ walkable_cells: list[tuple[int, int]],
89
+ cell_size: int,
82
90
  *,
83
91
  player: Player | None = None,
84
92
  camera: Camera | None = None,
@@ -104,10 +112,14 @@ def find_nearby_offscreen_spawn_position(
104
112
  None if max_player_dist is None else max_player_dist * max_player_dist
105
113
  )
106
114
  for _ in range(max(1, attempts)):
107
- cell = RNG.choice(walkable_cells)
108
- jitter_x = RNG.uniform(-cell.width * 0.35, cell.width * 0.35)
109
- jitter_y = RNG.uniform(-cell.height * 0.35, cell.height * 0.35)
110
- candidate = (int(cell.centerx + jitter_x), int(cell.centery + jitter_y))
115
+ cell_x, cell_y = RNG.choice(walkable_cells)
116
+ jitter_extent = cell_size * 0.35
117
+ jitter_x = RNG.uniform(-jitter_extent, jitter_extent)
118
+ jitter_y = RNG.uniform(-jitter_extent, jitter_extent)
119
+ candidate = (
120
+ int((cell_x * cell_size) + (cell_size / 2) + jitter_x),
121
+ int((cell_y * cell_size) + (cell_size / 2) + jitter_y),
122
+ )
111
123
  if player is not None and (
112
124
  min_distance_sq is not None or max_distance_sq is not None
113
125
  ):
@@ -123,8 +135,11 @@ def find_nearby_offscreen_spawn_position(
123
135
  return candidate
124
136
  if player is not None and (min_distance_sq is not None or max_distance_sq is not None):
125
137
  for _ in range(20):
126
- cell = RNG.choice(walkable_cells)
127
- center = (cell.centerx, cell.centery)
138
+ cell_x, cell_y = RNG.choice(walkable_cells)
139
+ center = (
140
+ (cell_x * cell_size) + (cell_size / 2),
141
+ (cell_y * cell_size) + (cell_size / 2),
142
+ )
128
143
  if view_rect is not None and view_rect.collidepoint(center):
129
144
  continue
130
145
  dx = center[0] - player.x
@@ -134,15 +149,19 @@ def find_nearby_offscreen_spawn_position(
134
149
  continue
135
150
  if max_distance_sq is not None and dist_sq > max_distance_sq:
136
151
  continue
137
- fallback_x = RNG.uniform(-cell.width * 0.2, cell.width * 0.2)
138
- fallback_y = RNG.uniform(-cell.height * 0.2, cell.height * 0.2)
139
- return (int(cell.centerx + fallback_x), int(cell.centery + fallback_y))
140
- fallback_cell = RNG.choice(walkable_cells)
141
- fallback_x = RNG.uniform(-fallback_cell.width * 0.35, fallback_cell.width * 0.35)
142
- fallback_y = RNG.uniform(-fallback_cell.height * 0.35, fallback_cell.height * 0.35)
152
+ fallback_extent = cell_size * 0.2
153
+ fallback_x = RNG.uniform(-fallback_extent, fallback_extent)
154
+ fallback_y = RNG.uniform(-fallback_extent, fallback_extent)
155
+ return (int(center[0] + fallback_x), int(center[1] + fallback_y))
156
+ fallback_cell_x, fallback_cell_y = RNG.choice(walkable_cells)
157
+ fallback_center_x = (fallback_cell_x * cell_size) + (cell_size / 2)
158
+ fallback_center_y = (fallback_cell_y * cell_size) + (cell_size / 2)
159
+ fallback_extent = cell_size * 0.35
160
+ fallback_x = RNG.uniform(-fallback_extent, fallback_extent)
161
+ fallback_y = RNG.uniform(-fallback_extent, fallback_extent)
143
162
  return (
144
- int(fallback_cell.centerx + fallback_x),
145
- int(fallback_cell.centery + fallback_y),
163
+ int(fallback_center_x + fallback_x),
164
+ int(fallback_center_y + fallback_y),
146
165
  )
147
166
 
148
167
 
@@ -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]]:
@@ -218,9 +299,10 @@ def _place_walls_grid_wire(
218
299
  grid[y][x] = "1"
219
300
 
220
301
 
221
- def _place_walls_sparse(
302
+ def _place_walls_sparse_moore(
222
303
  grid: list[list[str]],
223
304
  *,
305
+ density: float = SPARSE_WALL_DENSITY,
224
306
  forbidden_cells: set[tuple[int, int]] | None = None,
225
307
  ) -> None:
226
308
  """Place isolated wall tiles at a low density, avoiding adjacency."""
@@ -234,7 +316,7 @@ def _place_walls_sparse(
234
316
  continue
235
317
  if grid[y][x] != ".":
236
318
  continue
237
- if RNG.random() >= SPARSE_WALL_DENSITY:
319
+ if RNG.random() >= density:
238
320
  continue
239
321
  if (
240
322
  grid[y - 1][x] == "1"
@@ -250,11 +332,40 @@ def _place_walls_sparse(
250
332
  grid[y][x] = "1"
251
333
 
252
334
 
335
+ def _place_walls_sparse_ortho(
336
+ grid: list[list[str]],
337
+ *,
338
+ density: float = SPARSE_WALL_DENSITY,
339
+ forbidden_cells: set[tuple[int, int]] | None = None,
340
+ ) -> None:
341
+ """Place isolated wall tiles at a low density, avoiding orthogonal adjacency."""
342
+ cols, rows = len(grid[0]), len(grid)
343
+ forbidden = _collect_exit_adjacent_cells(grid)
344
+ if forbidden_cells:
345
+ forbidden |= forbidden_cells
346
+ for y in range(2, rows - 2):
347
+ for x in range(2, cols - 2):
348
+ if (x, y) in forbidden:
349
+ continue
350
+ if grid[y][x] != ".":
351
+ continue
352
+ if RNG.random() >= density:
353
+ continue
354
+ if (
355
+ grid[y - 1][x] == "1"
356
+ or grid[y + 1][x] == "1"
357
+ or grid[y][x - 1] == "1"
358
+ or grid[y][x + 1] == "1"
359
+ ):
360
+ continue
361
+ grid[y][x] = "1"
362
+
253
363
  WALL_ALGORITHMS = {
254
364
  "default": _place_walls_default,
255
365
  "empty": _place_walls_empty,
256
366
  "grid_wire": _place_walls_grid_wire,
257
- "sparse": _place_walls_sparse,
367
+ "sparse_moore": _place_walls_sparse_moore,
368
+ "sparse_ortho": _place_walls_sparse_ortho,
258
369
  }
259
370
 
260
371
 
@@ -281,6 +392,30 @@ def _place_steel_beams(
281
392
  return beams
282
393
 
283
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
+
284
419
  def _pick_empty_cell(
285
420
  grid: list[list[str]],
286
421
  margin: int,
@@ -302,7 +437,12 @@ def _pick_empty_cell(
302
437
 
303
438
 
304
439
  def _generate_random_blueprint(
305
- 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,
306
446
  ) -> dict:
307
447
  grid = _init_grid(cols, rows)
308
448
  _place_exits(grid, EXITS_PER_SIDE)
@@ -320,15 +460,90 @@ def _generate_random_blueprint(
320
460
  grid[zy][zx] = "Z"
321
461
  reserved_cells.add((zx, zy))
322
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
+
323
478
  # Select and run the wall placement algorithm (after reserving spawns)
479
+ sparse_density = SPARSE_WALL_DENSITY
480
+ original_wall_algo = wall_algo
481
+ if wall_algo == "sparse":
482
+ print(
483
+ "WARNING: 'sparse' is deprecated. Use 'sparse_moore' instead."
484
+ )
485
+ wall_algo = "sparse_moore"
486
+ elif wall_algo.startswith("sparse."):
487
+ print(
488
+ "WARNING: 'sparse.<int>%' is deprecated. Use "
489
+ "'sparse_moore.<int>%' instead."
490
+ )
491
+ suffix = wall_algo[len("sparse.") :]
492
+ wall_algo = "sparse_moore"
493
+ if suffix.endswith("%") and suffix[:-1].isdigit():
494
+ percent = int(suffix[:-1])
495
+ if 0 <= percent <= 100:
496
+ sparse_density = percent / 100.0
497
+ else:
498
+ print(
499
+ "WARNING: Sparse wall density must be 0-100%. "
500
+ f"Got '{suffix}'. Falling back to default sparse density."
501
+ )
502
+ else:
503
+ print(
504
+ "WARNING: Invalid sparse wall format. Use "
505
+ "'sparse_moore.<int>%' or 'sparse_ortho.<int>%'. "
506
+ f"Got '{original_wall_algo}'. Falling back to default sparse density."
507
+ )
508
+ if wall_algo.startswith("sparse_moore.") or wall_algo.startswith("sparse_ortho."):
509
+ base, suffix = wall_algo.split(".", 1)
510
+ if suffix.endswith("%") and suffix[:-1].isdigit():
511
+ percent = int(suffix[:-1])
512
+ if 0 <= percent <= 100:
513
+ sparse_density = percent / 100.0
514
+ wall_algo = base
515
+ else:
516
+ print(
517
+ "WARNING: Sparse wall density must be 0-100%. "
518
+ f"Got '{suffix}'. Falling back to default sparse density."
519
+ )
520
+ wall_algo = base
521
+ else:
522
+ print(
523
+ "WARNING: Invalid sparse wall format. Use "
524
+ "'sparse_moore.<int>%' or 'sparse_ortho.<int>%'. "
525
+ f"Got '{wall_algo}'. Falling back to default sparse density."
526
+ )
527
+ wall_algo = base
528
+
324
529
  if wall_algo not in WALL_ALGORITHMS:
325
530
  print(
326
531
  f"WARNING: Unknown wall algorithm '{wall_algo}'. Falling back to 'default'."
327
532
  )
328
533
  wall_algo = "default"
329
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
+
330
542
  algo_func = WALL_ALGORITHMS[wall_algo]
331
- algo_func(grid, forbidden_cells=reserved_cells)
543
+ if wall_algo in {"sparse_moore", "sparse_ortho"}:
544
+ algo_func(grid, density=sparse_density, forbidden_cells=reserved_cells)
545
+ else:
546
+ algo_func(grid, forbidden_cells=reserved_cells)
332
547
 
333
548
  steel_beams = _place_steel_beams(
334
549
  grid, chance=steel_chance, forbidden_cells=reserved_cells
@@ -339,7 +554,13 @@ def _generate_random_blueprint(
339
554
 
340
555
 
341
556
  def choose_blueprint(
342
- 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,
343
564
  ) -> dict:
344
565
  # Currently only random generation; hook for future variants.
345
566
  steel_conf = config.get("steel_beams", {})
@@ -347,6 +568,22 @@ def choose_blueprint(
347
568
  steel_chance = float(steel_conf.get("chance", STEEL_BEAM_CHANCE))
348
569
  except (TypeError, ValueError):
349
570
  steel_chance = STEEL_BEAM_CHANCE
350
- return _generate_random_blueprint(
351
- steel_chance=steel_chance, cols=cols, rows=rows, wall_algo=wall_algo
352
- )
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
  }
@@ -16,7 +16,7 @@
16
16
  "settings": "設定",
17
17
  "quit": "終了",
18
18
  "locked_suffix": "[Locked]",
19
- "window_hint": "[キーでウィンドウを小さく、]キーで大きく。\nF: フルスクリーン切替。\n(タイトル、設定画面のみ)",
19
+ "window_hint": "[キーでウィンドウを小さく、]キーで大きく。\nF: フルスクリーン切替。",
20
20
  "seed_label": "シード: %{value}",
21
21
  "seed_hint": "数字キーで入力、BSでクリア",
22
22
  "seed_empty": "(自動)",
@@ -25,6 +25,7 @@
25
25
  "resources": "リソース"
26
26
  },
27
27
  "readme": "README を開く",
28
+ "readme_stage6": "ステージ6以降の説明を開く",
28
29
  "hints": {
29
30
  "navigate": "上下: 項目選択",
30
31
  "page_switch": "左右: ステージ群切替",
@@ -33,7 +34,8 @@
33
34
  "option_help": {
34
35
  "settings": "設定画面を開いて言語や補助オプションを変更します。",
35
36
  "quit": "ゲームを終了します。",
36
- "readme": "GitHub の README ページをブラウザで開きます。"
37
+ "readme": "GitHub の README ページをブラウザで開きます。",
38
+ "readme_stage6": "ステージ6以降の説明ページをブラウザで開きます。"
37
39
  }
38
40
  },
39
41
  "stages": {
@@ -127,6 +129,10 @@
127
129
  "stage15": {
128
130
  "name": "#15 分断ライン",
129
131
  "description": "建物中央を分断する危険地帯。横断には注意。"
132
+ },
133
+ "stage16": {
134
+ "name": "#16 奈落",
135
+ "description": "崩落した床パネルを回り込んでゾンビを誘い込もう。"
130
136
  }
131
137
  },
132
138
  "status": {
@@ -198,10 +204,14 @@
198
204
  "game_over": {
199
205
  "win": "脱出成功!",
200
206
  "lose": "ゲームオーバー",
207
+ "fell_into_abyss": "奈落の底へ落ちてしまった...",
201
208
  "prompt": "ESC/Space/Select/South: タイトルへ R: 再挑戦",
202
209
  "scream": "ぎゃあーーー!!",
203
210
  "survivors_summary": "救出人数: %{count}",
204
211
  "endurance_duration": "逃げ延びた時間 %{time}"
212
+ },
213
+ "errors": {
214
+ "map_generation_failed": "マップの生成に失敗しました。シード値を変えるか、別のステージを選んでください。"
205
215
  }
206
216
  }
207
217
  }