zombie-escape 1.13.1__py3-none-any.whl → 1.14.4__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 (41) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/colors.py +7 -21
  3. zombie_escape/entities.py +100 -191
  4. zombie_escape/export_images.py +39 -33
  5. zombie_escape/gameplay/ambient.py +2 -6
  6. zombie_escape/gameplay/footprints.py +8 -11
  7. zombie_escape/gameplay/interactions.py +17 -58
  8. zombie_escape/gameplay/layout.py +20 -46
  9. zombie_escape/gameplay/movement.py +7 -21
  10. zombie_escape/gameplay/spawn.py +12 -40
  11. zombie_escape/gameplay/state.py +1 -0
  12. zombie_escape/gameplay/survivors.py +5 -16
  13. zombie_escape/gameplay/utils.py +4 -13
  14. zombie_escape/input_utils.py +8 -31
  15. zombie_escape/level_blueprints.py +112 -69
  16. zombie_escape/level_constants.py +8 -0
  17. zombie_escape/locales/ui.en.json +12 -0
  18. zombie_escape/locales/ui.ja.json +12 -0
  19. zombie_escape/localization.py +3 -11
  20. zombie_escape/models.py +26 -9
  21. zombie_escape/render/__init__.py +30 -0
  22. zombie_escape/render/core.py +992 -0
  23. zombie_escape/render/hud.py +444 -0
  24. zombie_escape/render/overview.py +218 -0
  25. zombie_escape/render/shadows.py +343 -0
  26. zombie_escape/render_assets.py +11 -33
  27. zombie_escape/rng.py +4 -8
  28. zombie_escape/screens/__init__.py +14 -30
  29. zombie_escape/screens/game_over.py +43 -15
  30. zombie_escape/screens/gameplay.py +41 -104
  31. zombie_escape/screens/settings.py +19 -104
  32. zombie_escape/screens/title.py +36 -176
  33. zombie_escape/stage_constants.py +192 -67
  34. zombie_escape/zombie_escape.py +1 -1
  35. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/METADATA +100 -39
  36. zombie_escape-1.14.4.dist-info/RECORD +53 -0
  37. zombie_escape/render.py +0 -1746
  38. zombie_escape-1.13.1.dist-info/RECORD +0 -49
  39. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/WHEEL +0 -0
  40. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/entry_points.txt +0 -0
  41. {zombie_escape-1.13.1.dist-info → zombie_escape-1.14.4.dist-info}/licenses/LICENSE.txt +0 -0
@@ -300,13 +300,8 @@ def _create_zombie(
300
300
  level_width = grid_cols * tile_size
301
301
  level_height = grid_rows * tile_size
302
302
  if hint_pos is not None:
303
- points = [
304
- random_position_outside_building(level_width, level_height)
305
- for _ in range(5)
306
- ]
307
- points.sort(
308
- key=lambda p: (p[0] - hint_pos[0]) ** 2 + (p[1] - hint_pos[1]) ** 2
309
- )
303
+ points = [random_position_outside_building(level_width, level_height) for _ in range(5)]
304
+ points.sort(key=lambda p: (p[0] - hint_pos[0]) ** 2 + (p[1] - hint_pos[1]) ** 2)
310
305
  start_pos = points[0]
311
306
  else:
312
307
  start_pos = random_position_outside_building(level_width, level_height)
@@ -392,9 +387,7 @@ def _place_flashlight(
392
387
  continue
393
388
  if cars:
394
389
  if any(
395
- (center[0] - parked.rect.centerx) ** 2
396
- + (center[1] - parked.rect.centery) ** 2
397
- < min_car_dist_sq
390
+ (center[0] - parked.rect.centerx) ** 2 + (center[1] - parked.rect.centery) ** 2 < min_car_dist_sq
398
391
  for parked in cars
399
392
  ):
400
393
  continue
@@ -431,9 +424,7 @@ def place_flashlights(
431
424
  break
432
425
  # Avoid clustering too tightly
433
426
  if any(
434
- (other.rect.centerx - fl.rect.centerx) ** 2
435
- + (other.rect.centery - fl.rect.centery) ** 2
436
- < 120 * 120
427
+ (other.rect.centerx - fl.rect.centerx) ** 2 + (other.rect.centery - fl.rect.centery) ** 2 < 120 * 120
437
428
  for other in placed
438
429
  ):
439
430
  continue
@@ -469,9 +460,7 @@ def _place_shoes(
469
460
  continue
470
461
  if cars:
471
462
  if any(
472
- (center[0] - parked.rect.centerx) ** 2
473
- + (center[1] - parked.rect.centery) ** 2
474
- < min_car_dist_sq
463
+ (center[0] - parked.rect.centerx) ** 2 + (center[1] - parked.rect.centery) ** 2 < min_car_dist_sq
475
464
  for parked in cars
476
465
  ):
477
466
  continue
@@ -507,9 +496,7 @@ def place_shoes(
507
496
  if not shoes:
508
497
  break
509
498
  if any(
510
- (other.rect.centerx - shoes.rect.centerx) ** 2
511
- + (other.rect.centery - shoes.rect.centery) ** 2
512
- < 120 * 120
499
+ (other.rect.centerx - shoes.rect.centerx) ** 2 + (other.rect.centery - shoes.rect.centery) ** 2 < 120 * 120
513
500
  for other in placed
514
501
  ):
515
502
  continue
@@ -568,30 +555,20 @@ def place_new_car(
568
555
  temp_car = Car(c_x, c_y, appearance=appearance)
569
556
  temp_rect = temp_car.rect.inflate(30, 30)
570
557
  nearby_walls = pygame.sprite.Group()
571
- nearby_walls.add(
572
- [
573
- w
574
- for w in wall_group
575
- if abs(w.rect.centerx - c_x) < 150 and abs(w.rect.centery - c_y) < 150
576
- ]
577
- )
558
+ nearby_walls.add([w for w in wall_group if abs(w.rect.centerx - c_x) < 150 and abs(w.rect.centery - c_y) < 150])
578
559
  collides_wall = spritecollideany_walls(temp_car, nearby_walls)
579
560
  collides_player = temp_rect.colliderect(player.rect.inflate(50, 50))
580
561
  car_overlap = False
581
562
  if existing_cars:
582
563
  car_overlap = any(
583
- temp_car.rect.colliderect(other.rect)
584
- for other in existing_cars
585
- if other and other.alive()
564
+ temp_car.rect.colliderect(other.rect) for other in existing_cars if other and other.alive()
586
565
  )
587
566
  if not collides_wall and not collides_player and not car_overlap:
588
567
  return temp_car
589
568
  return None
590
569
 
591
570
 
592
- def spawn_survivors(
593
- game_data: GameData, layout_data: Mapping[str, list[tuple[int, int]]]
594
- ) -> list[Survivor]:
571
+ def spawn_survivors(game_data: GameData, layout_data: Mapping[str, list[tuple[int, int]]]) -> list[Survivor]:
595
572
  """Populate rescue-stage survivors and buddy-stage buddies."""
596
573
  survivors: list[Survivor] = []
597
574
  if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
@@ -671,9 +648,7 @@ def setup_player_and_cars(
671
648
  RNG.shuffle(car_candidates)
672
649
  for candidate in car_candidates:
673
650
  center = _cell_center(candidate, cell_size)
674
- if (center[0] - player_pos[0]) ** 2 + (
675
- center[1] - player_pos[1]
676
- ) ** 2 >= 400 * 400:
651
+ if (center[0] - player_pos[0]) ** 2 + (center[1] - player_pos[1]) ** 2 >= 400 * 400:
677
652
  car_candidates.remove(candidate)
678
653
  return center
679
654
  choice = car_candidates.pop()
@@ -803,9 +778,7 @@ def spawn_waiting_car(game_data: GameData) -> Car | None:
803
778
  return None
804
779
 
805
780
 
806
- def maintain_waiting_car_supply(
807
- game_data: GameData, *, minimum: int | None = None
808
- ) -> None:
781
+ def maintain_waiting_car_supply(game_data: GameData, *, minimum: int | None = None) -> None:
809
782
  """Ensure a baseline count of parked cars exists."""
810
783
  target = 1 if minimum is None else max(0, minimum)
811
784
  current = len(_alive_waiting_cars(game_data))
@@ -839,8 +812,7 @@ def nearest_waiting_car(game_data: GameData, origin: tuple[float, float]) -> Car
839
812
  return None
840
813
  return min(
841
814
  cars,
842
- key=lambda car: (car.rect.centerx - origin[0]) ** 2
843
- + (car.rect.centery - origin[1]) ** 2,
815
+ key=lambda car: (car.rect.centerx - origin[0]) ** 2 + (car.rect.centery - origin[1]) ** 2,
844
816
  )
845
817
 
846
818
 
@@ -28,6 +28,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
28
28
  overview_created=False,
29
29
  footprints=[],
30
30
  last_footprint_pos=None,
31
+ footprint_visible_toggle=True,
31
32
  elapsed_play_ms=0,
32
33
  has_fuel=starts_with_fuel,
33
34
  flashlight_count=initial_flashlights,
@@ -63,9 +63,7 @@ def update_survivors(
63
63
  )
64
64
 
65
65
  # Gently prevent survivors from overlapping the player or each other
66
- def _separate_from_point(
67
- survivor: Survivor, point: tuple[float, float], min_dist: float
68
- ) -> None:
66
+ def _separate_from_point(survivor: Survivor, point: tuple[float, float], min_dist: float) -> None:
69
67
  dx = point[0] - survivor.x
70
68
  dy = point[1] - survivor.y
71
69
  dist = math.hypot(dx, dy)
@@ -86,9 +84,7 @@ def update_survivors(
86
84
  for survivor in survivors:
87
85
  _separate_from_point(survivor, player_point, player_overlap)
88
86
 
89
- survivors_with_x = sorted(
90
- ((survivor.x, survivor) for survivor in survivors), key=lambda item: item[0]
91
- )
87
+ survivors_with_x = sorted(((survivor.x, survivor) for survivor in survivors), key=lambda item: item[0])
92
88
  for i, (base_x, survivor) in enumerate(survivors_with_x):
93
89
  for other_base_x, other in survivors_with_x[i + 1 :]:
94
90
  if other_base_x - base_x > survivor_overlap:
@@ -112,10 +108,7 @@ def update_survivors(
112
108
  other.rect.center = (int(other.x), int(other.y))
113
109
 
114
110
 
115
-
116
- def calculate_car_speed_for_passengers(
117
- passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
118
- ) -> float:
111
+ def calculate_car_speed_for_passengers(passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS) -> float:
119
112
  cap = max(1, capacity)
120
113
  load_ratio = max(0.0, passengers / cap)
121
114
  penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * load_ratio
@@ -196,9 +189,7 @@ def random_survivor_conversion_line(stage_id: str) -> str:
196
189
 
197
190
  def cleanup_survivor_messages(state: ProgressState) -> None:
198
191
  now = pygame.time.get_ticks()
199
- state.survivor_messages = [
200
- msg for msg in state.survivor_messages if msg.get("expires_at", 0) > now
201
- ]
192
+ state.survivor_messages = [msg for msg in state.survivor_messages if msg.get("expires_at", 0) > now]
202
193
 
203
194
 
204
195
  def drop_survivors_from_car(game_data: GameData, origin: tuple[int, int]) -> None:
@@ -234,9 +225,7 @@ def drop_survivors_from_car(game_data: GameData, origin: tuple[int, int]) -> Non
234
225
  apply_passenger_speed_penalty(game_data)
235
226
 
236
227
 
237
- def handle_survivor_zombie_collisions(
238
- game_data: GameData, config: dict[str, Any]
239
- ) -> None:
228
+ def handle_survivor_zombie_collisions(game_data: GameData, config: dict[str, Any]) -> None:
240
229
  if not game_data.stage.rescue_stage:
241
230
  return
242
231
  survivor_group = game_data.groups.survivor_group
@@ -105,12 +105,8 @@ def find_nearby_offscreen_spawn_position(
105
105
  SCREEN_HEIGHT,
106
106
  )
107
107
  view_rect.inflate_ip(SCREEN_WIDTH, SCREEN_HEIGHT)
108
- min_distance_sq = (
109
- None if min_player_dist is None else min_player_dist * min_player_dist
110
- )
111
- max_distance_sq = (
112
- None if max_player_dist is None else max_player_dist * max_player_dist
113
- )
108
+ min_distance_sq = None if min_player_dist is None else min_player_dist * min_player_dist
109
+ max_distance_sq = None if max_player_dist is None else max_player_dist * max_player_dist
114
110
  for _ in range(max(1, attempts)):
115
111
  cell_x, cell_y = RNG.choice(walkable_cells)
116
112
  jitter_extent = cell_size * 0.35
@@ -120,9 +116,7 @@ def find_nearby_offscreen_spawn_position(
120
116
  int((cell_x * cell_size) + (cell_size / 2) + jitter_x),
121
117
  int((cell_y * cell_size) + (cell_size / 2) + jitter_y),
122
118
  )
123
- if player is not None and (
124
- min_distance_sq is not None or max_distance_sq is not None
125
- ):
119
+ if player is not None and (min_distance_sq is not None or max_distance_sq is not None):
126
120
  dx = candidate[0] - player.x
127
121
  dy = candidate[1] - player.y
128
122
  dist_sq = dx * dx + dy * dy
@@ -174,10 +168,7 @@ def find_exterior_spawn_position(
174
168
  ) -> tuple[int, int]:
175
169
  if hint_pos is None:
176
170
  return random_position_outside_building(level_width, level_height)
177
- points = [
178
- random_position_outside_building(level_width, level_height)
179
- for _ in range(max(1, attempts))
180
- ]
171
+ points = [random_position_outside_building(level_width, level_height) for _ in range(max(1, attempts))]
181
172
  return min(
182
173
  points,
183
174
  key=lambda pos: (pos[0] - hint_pos[0]) ** 2 + (pos[1] - hint_pos[1]) ** 2,
@@ -24,9 +24,7 @@ CONTROLLER_BUTTON_DPAD_RIGHT = getattr(pygame, "CONTROLLER_BUTTON_DPAD_RIGHT", N
24
24
  CONTROLLER_BUTTON_RB = getattr(pygame, "CONTROLLER_BUTTON_RIGHTSHOULDER", None)
25
25
  CONTROLLER_AXIS_LEFTX = getattr(pygame, "CONTROLLER_AXIS_LEFTX", None)
26
26
  CONTROLLER_AXIS_LEFTY = getattr(pygame, "CONTROLLER_AXIS_LEFTY", None)
27
- CONTROLLER_AXIS_TRIGGERRIGHT = getattr(
28
- pygame, "CONTROLLER_AXIS_TRIGGERRIGHT", None
29
- )
27
+ CONTROLLER_AXIS_TRIGGERRIGHT = getattr(pygame, "CONTROLLER_AXIS_TRIGGERRIGHT", None)
30
28
 
31
29
 
32
30
  def init_first_controller() -> pygame.controller.Controller | None:
@@ -65,10 +63,7 @@ def is_confirm_event(event: pygame.event.Event) -> bool:
65
63
 
66
64
  def is_start_event(event: pygame.event.Event) -> bool:
67
65
  if CONTROLLER_BUTTON_DOWN is not None and event.type == CONTROLLER_BUTTON_DOWN:
68
- return (
69
- CONTROLLER_BUTTON_START is not None
70
- and event.button == CONTROLLER_BUTTON_START
71
- )
66
+ return CONTROLLER_BUTTON_START is not None and event.button == CONTROLLER_BUTTON_START
72
67
  if event.type == pygame.JOYBUTTONDOWN:
73
68
  return event.button == JOY_BUTTON_START
74
69
  return False
@@ -76,10 +71,7 @@ def is_start_event(event: pygame.event.Event) -> bool:
76
71
 
77
72
  def is_select_event(event: pygame.event.Event) -> bool:
78
73
  if CONTROLLER_BUTTON_DOWN is not None and event.type == CONTROLLER_BUTTON_DOWN:
79
- return (
80
- CONTROLLER_BUTTON_BACK is not None
81
- and event.button == CONTROLLER_BUTTON_BACK
82
- )
74
+ return CONTROLLER_BUTTON_BACK is not None and event.button == CONTROLLER_BUTTON_BACK
83
75
  if event.type == pygame.JOYBUTTONDOWN:
84
76
  return event.button == JOY_BUTTON_BACK
85
77
  return False
@@ -102,25 +94,13 @@ def read_gamepad_move(
102
94
  x = 0.0
103
95
  if abs(y) < deadzone:
104
96
  y = 0.0
105
- if (
106
- CONTROLLER_BUTTON_DPAD_LEFT is not None
107
- and controller.get_button(CONTROLLER_BUTTON_DPAD_LEFT)
108
- ):
97
+ if CONTROLLER_BUTTON_DPAD_LEFT is not None and controller.get_button(CONTROLLER_BUTTON_DPAD_LEFT):
109
98
  x = -1.0
110
- elif (
111
- CONTROLLER_BUTTON_DPAD_RIGHT is not None
112
- and controller.get_button(CONTROLLER_BUTTON_DPAD_RIGHT)
113
- ):
99
+ elif CONTROLLER_BUTTON_DPAD_RIGHT is not None and controller.get_button(CONTROLLER_BUTTON_DPAD_RIGHT):
114
100
  x = 1.0
115
- if (
116
- CONTROLLER_BUTTON_DPAD_UP is not None
117
- and controller.get_button(CONTROLLER_BUTTON_DPAD_UP)
118
- ):
101
+ if CONTROLLER_BUTTON_DPAD_UP is not None and controller.get_button(CONTROLLER_BUTTON_DPAD_UP):
119
102
  y = -1.0
120
- elif (
121
- CONTROLLER_BUTTON_DPAD_DOWN is not None
122
- and controller.get_button(CONTROLLER_BUTTON_DPAD_DOWN)
123
- ):
103
+ elif CONTROLLER_BUTTON_DPAD_DOWN is not None and controller.get_button(CONTROLLER_BUTTON_DPAD_DOWN):
124
104
  y = 1.0
125
105
  return x, y
126
106
 
@@ -149,10 +129,7 @@ def is_accel_active(
149
129
  if keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT]:
150
130
  return True
151
131
  if controller and controller.get_init():
152
- if (
153
- CONTROLLER_BUTTON_RB is not None
154
- and controller.get_button(CONTROLLER_BUTTON_RB)
155
- ):
132
+ if CONTROLLER_BUTTON_RB is not None and controller.get_button(CONTROLLER_BUTTON_RB):
156
133
  return True
157
134
  if CONTROLLER_AXIS_TRIGGERRIGHT is not None:
158
135
  if controller.get_axis(CONTROLLER_AXIS_TRIGGERRIGHT) > DEADZONE:
@@ -2,18 +2,21 @@
2
2
 
3
3
  from collections import deque
4
4
 
5
+ from .level_constants import (
6
+ DEFAULT_GRID_WIRE_WALL_LINES,
7
+ DEFAULT_SPARSE_WALL_DENSITY,
8
+ DEFAULT_STEEL_BEAM_CHANCE,
9
+ DEFAULT_WALL_LINES,
10
+ )
5
11
  from .rng import get_rng, seed_rng
6
12
 
7
13
  EXITS_PER_SIDE = 1 # currently fixed to 1 per side (can be tuned)
8
- NUM_WALL_LINES = 80 # reduced density (roughly 1/5 of previous 450)
9
14
  WALL_MIN_LEN = 3
10
15
  WALL_MAX_LEN = 10
11
- SPARSE_WALL_DENSITY = 0.10
12
16
  SPAWN_MARGIN = 3 # keep spawns away from walls/edges
13
17
  SPAWN_ZOMBIES = 3
14
18
 
15
19
  RNG = get_rng()
16
- STEEL_BEAM_CHANCE = 0.02
17
20
 
18
21
 
19
22
  class MapGenerationError(Exception):
@@ -168,6 +171,7 @@ def _place_exits(grid: list[list[str]], exits_per_side: int) -> None:
168
171
  def _place_walls_default(
169
172
  grid: list[list[str]],
170
173
  *,
174
+ line_count: int = DEFAULT_WALL_LINES,
171
175
  forbidden_cells: set[tuple[int, int]] | None = None,
172
176
  ) -> None:
173
177
  cols, rows = len(grid[0]), len(grid)
@@ -177,7 +181,7 @@ def _place_walls_default(
177
181
  if forbidden_cells:
178
182
  forbidden |= forbidden_cells
179
183
 
180
- for _ in range(NUM_WALL_LINES):
184
+ for _ in range(line_count):
181
185
  length = rng(WALL_MIN_LEN, WALL_MAX_LEN)
182
186
  horizontal = RNG.choice([True, False])
183
187
  if horizontal:
@@ -210,6 +214,7 @@ def _place_walls_empty(
210
214
  def _place_walls_grid_wire(
211
215
  grid: list[list[str]],
212
216
  *,
217
+ line_count: int = DEFAULT_GRID_WIRE_WALL_LINES,
213
218
  forbidden_cells: set[tuple[int, int]] | None = None,
214
219
  ) -> None:
215
220
  """
@@ -230,8 +235,7 @@ def _place_walls_grid_wire(
230
235
  grid_v = [["." for _ in range(cols)] for _ in range(rows)]
231
236
  grid_h = [["." for _ in range(cols)] for _ in range(rows)]
232
237
 
233
- # Use a similar density to default.
234
- lines_per_pass = int(NUM_WALL_LINES * 0.7)
238
+ lines_per_pass = line_count
235
239
 
236
240
  # --- Pass 1: Vertical Walls (on grid_v) ---
237
241
  for _ in range(lines_per_pass):
@@ -239,24 +243,28 @@ def _place_walls_grid_wire(
239
243
  x = rng(2, cols - 3)
240
244
  y = rng(2, rows - 2 - length)
241
245
 
246
+ # Reject if the new segment would connect end-to-end with another vertical segment.
242
247
  can_place = True
243
- for i in range(length):
244
- cy = y + i
245
- # 1. Global forbidden check (exits, outer walls in main grid)
246
- if (x, cy) in forbidden:
247
- can_place = False
248
- break
249
- if grid[cy][x] not in (".",):
250
- can_place = False
251
- break
252
- # 2. Local self-overlap check
253
- if grid_v[cy][x] != ".":
254
- can_place = False
255
- break
256
- # 3. Parallel adjacency check (only against other vertical walls)
257
- if grid_v[cy][x - 1] == "1" or grid_v[cy][x + 1] == "1":
258
- can_place = False
259
- break
248
+ if grid_v[y - 1][x] == "1" or grid_v[y + length][x] == "1":
249
+ can_place = False
250
+ else:
251
+ for i in range(length):
252
+ cy = y + i
253
+ # 1. Global forbidden check (exits, outer walls in main grid)
254
+ if (x, cy) in forbidden:
255
+ can_place = False
256
+ break
257
+ if grid[cy][x] not in (".",):
258
+ can_place = False
259
+ break
260
+ # 2. Local self-overlap check
261
+ if grid_v[cy][x] != ".":
262
+ can_place = False
263
+ break
264
+ # 3. Parallel adjacency check (only against other vertical walls)
265
+ if grid_v[cy][x - 1] == "1" or grid_v[cy][x + 1] == "1":
266
+ can_place = False
267
+ break
260
268
 
261
269
  if can_place:
262
270
  for i in range(length):
@@ -268,24 +276,28 @@ def _place_walls_grid_wire(
268
276
  x = rng(2, cols - 2 - length)
269
277
  y = rng(2, rows - 3)
270
278
 
279
+ # Reject if the new segment would connect end-to-end with another horizontal segment.
271
280
  can_place = True
272
- for i in range(length):
273
- cx = x + i
274
- # 1. Global forbidden check
275
- if (cx, y) in forbidden:
276
- can_place = False
277
- break
278
- if grid[y][cx] not in (".",):
279
- can_place = False
280
- break
281
- # 2. Local self-overlap check
282
- if grid_h[y][cx] != ".":
283
- can_place = False
284
- break
285
- # 3. Parallel adjacency check (only against other horizontal walls)
286
- if grid_h[y - 1][cx] == "1" or grid_h[y + 1][cx] == "1":
287
- can_place = False
288
- break
281
+ if grid_h[y][x - 1] == "1" or grid_h[y][x + length] == "1":
282
+ can_place = False
283
+ else:
284
+ for i in range(length):
285
+ cx = x + i
286
+ # 1. Global forbidden check
287
+ if (cx, y) in forbidden:
288
+ can_place = False
289
+ break
290
+ if grid[y][cx] not in (".",):
291
+ can_place = False
292
+ break
293
+ # 2. Local self-overlap check
294
+ if grid_h[y][cx] != ".":
295
+ can_place = False
296
+ break
297
+ # 3. Parallel adjacency check (only against other horizontal walls)
298
+ if grid_h[y - 1][cx] == "1" or grid_h[y + 1][cx] == "1":
299
+ can_place = False
300
+ break
289
301
 
290
302
  if can_place:
291
303
  for i in range(length):
@@ -302,7 +314,7 @@ def _place_walls_grid_wire(
302
314
  def _place_walls_sparse_moore(
303
315
  grid: list[list[str]],
304
316
  *,
305
- density: float = SPARSE_WALL_DENSITY,
317
+ density: float = DEFAULT_SPARSE_WALL_DENSITY,
306
318
  forbidden_cells: set[tuple[int, int]] | None = None,
307
319
  ) -> None:
308
320
  """Place isolated wall tiles at a low density, avoiding adjacency."""
@@ -335,7 +347,7 @@ def _place_walls_sparse_moore(
335
347
  def _place_walls_sparse_ortho(
336
348
  grid: list[list[str]],
337
349
  *,
338
- density: float = SPARSE_WALL_DENSITY,
350
+ density: float = DEFAULT_SPARSE_WALL_DENSITY,
339
351
  forbidden_cells: set[tuple[int, int]] | None = None,
340
352
  ) -> None:
341
353
  """Place isolated wall tiles at a low density, avoiding orthogonal adjacency."""
@@ -351,15 +363,11 @@ def _place_walls_sparse_ortho(
351
363
  continue
352
364
  if RNG.random() >= density:
353
365
  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
- ):
366
+ if grid[y - 1][x] == "1" or grid[y + 1][x] == "1" or grid[y][x - 1] == "1" or grid[y][x + 1] == "1":
360
367
  continue
361
368
  grid[y][x] = "1"
362
369
 
370
+
363
371
  WALL_ALGORITHMS = {
364
372
  "default": _place_walls_default,
365
373
  "empty": _place_walls_empty,
@@ -372,7 +380,7 @@ WALL_ALGORITHMS = {
372
380
  def _place_steel_beams(
373
381
  grid: list[list[str]],
374
382
  *,
375
- chance: float = STEEL_BEAM_CHANCE,
383
+ chance: float = DEFAULT_STEEL_BEAM_CHANCE,
376
384
  forbidden_cells: set[tuple[int, int]] | None = None,
377
385
  ) -> set[tuple[int, int]]:
378
386
  """Pick individual cells for steel beams, avoiding exits and their neighbors."""
@@ -396,16 +404,32 @@ def _place_pitfalls(
396
404
  grid: list[list[str]],
397
405
  *,
398
406
  density: float,
407
+ pitfall_zones: list[tuple[int, int, int, int]] | None = None,
399
408
  forbidden_cells: set[tuple[int, int]] | None = None,
400
409
  ) -> None:
401
410
  """Replace empty floor tiles with pitfalls based on density."""
402
- if density <= 0.0:
403
- return
404
411
  cols, rows = len(grid[0]), len(grid)
405
412
  forbidden = _collect_exit_adjacent_cells(grid)
406
413
  if forbidden_cells:
407
414
  forbidden |= forbidden_cells
408
415
 
416
+ if pitfall_zones:
417
+ for col, row, width, height in pitfall_zones:
418
+ if width <= 0 or height <= 0:
419
+ continue
420
+ start_x = max(0, col)
421
+ start_y = max(0, row)
422
+ end_x = min(cols, col + width)
423
+ end_y = min(rows, row + height)
424
+ for y in range(start_y, end_y):
425
+ for x in range(start_x, end_x):
426
+ if (x, y) in forbidden:
427
+ continue
428
+ if grid[y][x] == ".":
429
+ grid[y][x] = "x"
430
+
431
+ if density <= 0.0:
432
+ return
409
433
  for y in range(1, rows - 1):
410
434
  for x in range(1, cols - 1):
411
435
  if (x, y) in forbidden:
@@ -443,6 +467,7 @@ def _generate_random_blueprint(
443
467
  rows: int,
444
468
  wall_algo: str = "default",
445
469
  pitfall_density: float = 0.0,
470
+ pitfall_zones: list[tuple[int, int, int, int]] | None = None,
446
471
  ) -> dict:
447
472
  grid = _init_grid(cols, rows)
448
473
  _place_exits(grid, EXITS_PER_SIDE)
@@ -476,18 +501,14 @@ def _generate_random_blueprint(
476
501
  reserved_cells.add((sx, sy))
477
502
 
478
503
  # Select and run the wall placement algorithm (after reserving spawns)
479
- sparse_density = SPARSE_WALL_DENSITY
504
+ sparse_density = DEFAULT_SPARSE_WALL_DENSITY
505
+ wall_line_count = DEFAULT_WALL_LINES
480
506
  original_wall_algo = wall_algo
481
507
  if wall_algo == "sparse":
482
- print(
483
- "WARNING: 'sparse' is deprecated. Use 'sparse_moore' instead."
484
- )
508
+ print("WARNING: 'sparse' is deprecated. Use 'sparse_moore' instead.")
485
509
  wall_algo = "sparse_moore"
486
510
  elif wall_algo.startswith("sparse."):
487
- print(
488
- "WARNING: 'sparse.<int>%' is deprecated. Use "
489
- "'sparse_moore.<int>%' instead."
490
- )
511
+ print("WARNING: 'sparse.<int>%' is deprecated. Use 'sparse_moore.<int>%' instead.")
491
512
  suffix = wall_algo[len("sparse.") :]
492
513
  wall_algo = "sparse_moore"
493
514
  if suffix.endswith("%") and suffix[:-1].isdigit():
@@ -505,16 +526,37 @@ def _generate_random_blueprint(
505
526
  "'sparse_moore.<int>%' or 'sparse_ortho.<int>%'. "
506
527
  f"Got '{original_wall_algo}'. Falling back to default sparse density."
507
528
  )
529
+ if wall_algo.startswith("default.") or wall_algo.startswith("grid_wire."):
530
+ base, suffix = wall_algo.split(".", 1)
531
+ base_line_count = DEFAULT_WALL_LINES if base == "default" else DEFAULT_GRID_WIRE_WALL_LINES
532
+ if suffix.endswith("%") and suffix[:-1].isdigit():
533
+ percent = int(suffix[:-1])
534
+ if 0 <= percent <= 200:
535
+ wall_line_count = int(base_line_count * (percent / 100.0))
536
+ wall_algo = base
537
+ else:
538
+ print(
539
+ "WARNING: Wall line density must be 0-200%. "
540
+ f"Got '{suffix}'. Falling back to default line count."
541
+ )
542
+ wall_algo = base
543
+ else:
544
+ print(
545
+ "WARNING: Invalid wall line format. Use "
546
+ "'default.<int>%' or 'grid_wire.<int>%'. "
547
+ f"Got '{wall_algo}'. Falling back to default line count."
548
+ )
549
+ wall_algo = base
508
550
  if wall_algo.startswith("sparse_moore.") or wall_algo.startswith("sparse_ortho."):
509
551
  base, suffix = wall_algo.split(".", 1)
510
552
  if suffix.endswith("%") and suffix[:-1].isdigit():
511
553
  percent = int(suffix[:-1])
512
- if 0 <= percent <= 100:
554
+ if 0 <= percent <= 200:
513
555
  sparse_density = percent / 100.0
514
556
  wall_algo = base
515
557
  else:
516
558
  print(
517
- "WARNING: Sparse wall density must be 0-100%. "
559
+ "WARNING: Sparse wall density must be 0-200%. "
518
560
  f"Got '{suffix}'. Falling back to default sparse density."
519
561
  )
520
562
  wall_algo = base
@@ -527,27 +569,26 @@ def _generate_random_blueprint(
527
569
  wall_algo = base
528
570
 
529
571
  if wall_algo not in WALL_ALGORITHMS:
530
- print(
531
- f"WARNING: Unknown wall algorithm '{wall_algo}'. Falling back to 'default'."
532
- )
572
+ print(f"WARNING: Unknown wall algorithm '{wall_algo}'. Falling back to 'default'.")
533
573
  wall_algo = "default"
534
574
 
535
575
  # Place pitfalls BEFORE walls so walls avoid them (consistent with spawn reservation)
536
576
  _place_pitfalls(
537
577
  grid,
538
578
  density=pitfall_density,
579
+ pitfall_zones=pitfall_zones,
539
580
  forbidden_cells=reserved_cells,
540
581
  )
541
582
 
542
583
  algo_func = WALL_ALGORITHMS[wall_algo]
543
584
  if wall_algo in {"sparse_moore", "sparse_ortho"}:
544
585
  algo_func(grid, density=sparse_density, forbidden_cells=reserved_cells)
586
+ elif wall_algo in {"default", "grid_wire"}:
587
+ algo_func(grid, line_count=wall_line_count, forbidden_cells=reserved_cells)
545
588
  else:
546
589
  algo_func(grid, forbidden_cells=reserved_cells)
547
590
 
548
- steel_beams = _place_steel_beams(
549
- grid, chance=steel_chance, forbidden_cells=reserved_cells
550
- )
591
+ steel_beams = _place_steel_beams(grid, chance=steel_chance, forbidden_cells=reserved_cells)
551
592
 
552
593
  blueprint_rows = ["".join(row) for row in grid]
553
594
  return {"grid": blueprint_rows, "steel_cells": steel_beams}
@@ -560,14 +601,15 @@ def choose_blueprint(
560
601
  rows: int,
561
602
  wall_algo: str = "default",
562
603
  pitfall_density: float = 0.0,
604
+ pitfall_zones: list[tuple[int, int, int, int]] | None = None,
563
605
  base_seed: int | None = None,
564
606
  ) -> dict:
565
607
  # Currently only random generation; hook for future variants.
566
608
  steel_conf = config.get("steel_beams", {})
567
609
  try:
568
- steel_chance = float(steel_conf.get("chance", STEEL_BEAM_CHANCE))
610
+ steel_chance = float(steel_conf.get("chance", DEFAULT_STEEL_BEAM_CHANCE))
569
611
  except (TypeError, ValueError):
570
- steel_chance = STEEL_BEAM_CHANCE
612
+ steel_chance = DEFAULT_STEEL_BEAM_CHANCE
571
613
 
572
614
  for attempt in range(20):
573
615
  if base_seed is not None:
@@ -579,6 +621,7 @@ def choose_blueprint(
579
621
  rows=rows,
580
622
  wall_algo=wall_algo,
581
623
  pitfall_density=pitfall_density,
624
+ pitfall_zones=pitfall_zones,
582
625
  )
583
626
 
584
627
  car_reachable = validate_connectivity(blueprint["grid"])
@@ -5,9 +5,17 @@ from __future__ import annotations
5
5
  DEFAULT_GRID_COLS = 48
6
6
  DEFAULT_GRID_ROWS = 30
7
7
  DEFAULT_TILE_SIZE = 50 # world units per cell; adjust to scale the whole map
8
+ DEFAULT_WALL_LINES = 80 # reduced density (roughly 1/5 of previous 450)
9
+ DEFAULT_GRID_WIRE_WALL_LINES = int(DEFAULT_WALL_LINES * 0.7)
10
+ DEFAULT_SPARSE_WALL_DENSITY = 0.10
11
+ DEFAULT_STEEL_BEAM_CHANCE = 0.02
8
12
 
9
13
  __all__ = [
10
14
  "DEFAULT_GRID_COLS",
11
15
  "DEFAULT_GRID_ROWS",
12
16
  "DEFAULT_TILE_SIZE",
17
+ "DEFAULT_WALL_LINES",
18
+ "DEFAULT_GRID_WIRE_WALL_LINES",
19
+ "DEFAULT_SPARSE_WALL_DENSITY",
20
+ "DEFAULT_STEEL_BEAM_CHANCE",
13
21
  ]