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
@@ -41,7 +41,7 @@ from .render_assets import (
41
41
  resolve_wall_colors,
42
42
  RUBBLE_ROTATION_DEG,
43
43
  )
44
- from .colors import FALL_ZONE_FLOOR_PRIMARY
44
+ from .colors import FALL_ZONE_FLOOR_PRIMARY, FALL_ZONE_FLOOR_SECONDARY
45
45
  from .render_constants import (
46
46
  FALLING_ZOMBIE_COLOR,
47
47
  PITFALL_ABYSS_COLOR,
@@ -67,14 +67,15 @@ def _ensure_pygame_ready() -> None:
67
67
  pygame.display.set_mode((1, 1), flags=flags)
68
68
 
69
69
 
70
- def _save_surface(surface: pygame.Surface, path: Path) -> None:
70
+ def _save_surface(surface: pygame.Surface, path: Path, *, scale: int = 1) -> None:
71
+ if scale != 1:
72
+ width, height = surface.get_size()
73
+ surface = pygame.transform.scale(surface, (width * scale, height * scale))
71
74
  path.parent.mkdir(parents=True, exist_ok=True)
72
75
  pygame.image.save(surface, str(path))
73
76
 
74
77
 
75
- def _pick_directional_surface(
76
- surfaces: list[pygame.Surface], *, bin_index: int = 0
77
- ) -> pygame.Surface:
78
+ def _pick_directional_surface(surfaces: list[pygame.Surface], *, bin_index: int = 0) -> pygame.Surface:
78
79
  if not surfaces:
79
80
  return pygame.Surface((1, 1), pygame.SRCALPHA)
80
81
  return surfaces[bin_index % len(surfaces)]
@@ -87,10 +88,7 @@ def _build_pitfall_tile(cell_size: int) -> pygame.Surface:
87
88
 
88
89
  for i in range(PITFALL_SHADOW_WIDTH):
89
90
  t = i / (PITFALL_SHADOW_WIDTH - 1.0)
90
- color = tuple(
91
- int(PITFALL_SHADOW_RIM_COLOR[j] * (1.0 - t) + PITFALL_ABYSS_COLOR[j] * t)
92
- for j in range(3)
93
- )
91
+ color = tuple(int(PITFALL_SHADOW_RIM_COLOR[j] * (1.0 - t) + PITFALL_ABYSS_COLOR[j] * t) for j in range(3))
94
92
  pygame.draw.line(
95
93
  surface,
96
94
  color,
@@ -118,7 +116,7 @@ def _build_pitfall_tile(cell_size: int) -> pygame.Surface:
118
116
  return surface
119
117
 
120
118
 
121
- def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> list[Path]:
119
+ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE, output_scale: int = 4) -> list[Path]:
122
120
  _ensure_pygame_ready()
123
121
 
124
122
  saved: list[Path] = []
@@ -129,7 +127,7 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
129
127
  bin_index=0,
130
128
  )
131
129
  player_path = out / "player.png"
132
- _save_surface(player, player_path)
130
+ _save_surface(player, player_path, scale=output_scale)
133
131
  saved.append(player_path)
134
132
 
135
133
  zombie_base = _pick_directional_surface(
@@ -137,7 +135,7 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
137
135
  bin_index=0,
138
136
  )
139
137
  zombie_normal_path = out / "zombie-normal.png"
140
- _save_surface(zombie_base, zombie_normal_path)
138
+ _save_surface(zombie_base, zombie_normal_path, scale=output_scale)
141
139
  saved.append(zombie_normal_path)
142
140
 
143
141
  tracker = zombie_base.copy()
@@ -148,7 +146,7 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
148
146
  color=ZOMBIE_NOSE_COLOR,
149
147
  )
150
148
  tracker_path = out / "zombie-tracker.png"
151
- _save_surface(tracker, tracker_path)
149
+ _save_surface(tracker, tracker_path, scale=output_scale)
152
150
  saved.append(tracker_path)
153
151
 
154
152
  wall_hugging = zombie_base.copy()
@@ -159,7 +157,7 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
159
157
  color=ZOMBIE_NOSE_COLOR,
160
158
  )
161
159
  wall_path = out / "zombie-wall.png"
162
- _save_surface(wall_hugging, wall_path)
160
+ _save_surface(wall_hugging, wall_path, scale=output_scale)
163
161
  saved.append(wall_path)
164
162
 
165
163
  buddy = _pick_directional_surface(
@@ -171,7 +169,7 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
171
169
  bin_index=0,
172
170
  )
173
171
  buddy_path = out / "buddy.png"
174
- _save_surface(buddy, buddy_path)
172
+ _save_surface(buddy, buddy_path, scale=output_scale)
175
173
  saved.append(buddy_path)
176
174
 
177
175
  survivor = _pick_directional_surface(
@@ -183,7 +181,7 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
183
181
  bin_index=0,
184
182
  )
185
183
  survivor_path = out / "survivor.png"
186
- _save_surface(survivor, survivor_path)
184
+ _save_surface(survivor, survivor_path, scale=output_scale)
187
185
  saved.append(survivor_path)
188
186
 
189
187
  car_surface = build_car_surface(CAR_WIDTH, CAR_HEIGHT)
@@ -196,27 +194,27 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
196
194
  )
197
195
  car = _pick_directional_surface(build_car_directional_surfaces(car_surface), bin_index=0)
198
196
  car_path = out / "car.png"
199
- _save_surface(car, car_path)
197
+ _save_surface(car, car_path, scale=output_scale)
200
198
  saved.append(car_path)
201
199
 
202
200
  fuel = build_fuel_can_surface(FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT)
203
201
  fuel_path = out / "fuel.png"
204
- _save_surface(fuel, fuel_path)
202
+ _save_surface(fuel, fuel_path, scale=output_scale)
205
203
  saved.append(fuel_path)
206
204
 
207
205
  flashlight = build_flashlight_surface(FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT)
208
206
  flashlight_path = out / "flashlight.png"
209
- _save_surface(flashlight, flashlight_path)
207
+ _save_surface(flashlight, flashlight_path, scale=output_scale)
210
208
  saved.append(flashlight_path)
211
209
 
212
210
  shoes = build_shoes_surface(SHOES_WIDTH, SHOES_HEIGHT)
213
211
  shoes_path = out / "shoes.png"
214
- _save_surface(shoes, shoes_path)
212
+ _save_surface(shoes, shoes_path, scale=output_scale)
215
213
  saved.append(shoes_path)
216
214
 
217
215
  beam = SteelBeam(0, 0, cell_size, health=STEEL_BEAM_HEALTH, palette=None)
218
216
  beam_path = out / "steel-beam.png"
219
- _save_surface(beam.image, beam_path)
217
+ _save_surface(beam.image, beam_path, scale=output_scale)
220
218
  saved.append(beam_path)
221
219
 
222
220
  inner_wall = pygame.Surface((cell_size, cell_size), pygame.SRCALPHA)
@@ -230,13 +228,13 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
230
228
  fill_color=inner_fill,
231
229
  border_color=inner_border,
232
230
  bevel_depth=INTERNAL_WALL_BEVEL_DEPTH,
233
- bevel_mask=(False, False, False, False),
234
- draw_bottom_side=False,
231
+ bevel_mask=(True, True, True, True),
232
+ draw_bottom_side=True,
235
233
  bottom_side_ratio=0.1,
236
234
  side_shade_ratio=0.9,
237
235
  )
238
236
  inner_wall_path = out / "wall-inner.png"
239
- _save_surface(inner_wall, inner_wall_path)
237
+ _save_surface(inner_wall, inner_wall_path, scale=output_scale)
240
238
  saved.append(inner_wall_path)
241
239
 
242
240
  rubble_wall = build_rubble_wall_surface(
@@ -246,7 +244,7 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
246
244
  angle_deg=RUBBLE_ROTATION_DEG,
247
245
  )
248
246
  rubble_wall_path = out / "wall-rubble.png"
249
- _save_surface(rubble_wall, rubble_wall_path)
247
+ _save_surface(rubble_wall, rubble_wall_path, scale=output_scale)
250
248
  saved.append(rubble_wall_path)
251
249
 
252
250
  outer_wall = pygame.Surface((cell_size, cell_size), pygame.SRCALPHA)
@@ -260,18 +258,18 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
260
258
  fill_color=outer_fill,
261
259
  border_color=outer_border,
262
260
  bevel_depth=0,
263
- bevel_mask=(False, False, False, False),
264
- draw_bottom_side=False,
261
+ bevel_mask=(True, True, True, True),
262
+ draw_bottom_side=True,
265
263
  bottom_side_ratio=0.1,
266
264
  side_shade_ratio=0.9,
267
265
  )
268
266
  outer_wall_path = out / "wall-outer.png"
269
- _save_surface(outer_wall, outer_wall_path)
267
+ _save_surface(outer_wall, outer_wall_path, scale=output_scale)
270
268
  saved.append(outer_wall_path)
271
269
 
272
270
  pitfall = _build_pitfall_tile(cell_size)
273
271
  pitfall_path = out / "pitfall.png"
274
- _save_surface(pitfall, pitfall_path)
272
+ _save_surface(pitfall, pitfall_path, scale=output_scale)
275
273
  saved.append(pitfall_path)
276
274
 
277
275
  fall_radius = max(1, int(ZOMBIE_RADIUS))
@@ -284,13 +282,21 @@ def export_images(output_dir: Path, *, cell_size: int = DEFAULT_TILE_SIZE) -> li
284
282
  fall_radius,
285
283
  )
286
284
  falling_path = out / "falling-zombie.png"
287
- _save_surface(falling, falling_path)
285
+ _save_surface(falling, falling_path, scale=output_scale)
288
286
  saved.append(falling_path)
289
287
 
290
- fall_zone = pygame.Surface((cell_size, cell_size), pygame.SRCALPHA)
291
- fall_zone.fill(FALL_ZONE_FLOOR_PRIMARY)
288
+ fall_zone_size = cell_size * 2
289
+ fall_zone = pygame.Surface((fall_zone_size, fall_zone_size), pygame.SRCALPHA)
290
+ for y in range(2):
291
+ for x in range(2):
292
+ color = FALL_ZONE_FLOOR_SECONDARY if (x + y) % 2 == 0 else FALL_ZONE_FLOOR_PRIMARY
293
+ pygame.draw.rect(
294
+ fall_zone,
295
+ color,
296
+ (x * cell_size, y * cell_size, cell_size, cell_size),
297
+ )
292
298
  fall_zone_path = out / "fall-zone.png"
293
- _save_surface(fall_zone, fall_zone_path)
299
+ _save_surface(fall_zone, fall_zone_path, scale=output_scale)
294
300
  saved.append(fall_zone_path)
295
301
 
296
302
  return saved
@@ -8,9 +8,7 @@ from ..colors import (
8
8
  from ..models import GameData
9
9
 
10
10
 
11
- def _set_ambient_palette(
12
- game_data: GameData, key: str, *, force: bool = False
13
- ) -> None:
11
+ def _set_ambient_palette(game_data: GameData, key: str, *, force: bool = False) -> None:
14
12
  """Apply a named ambient palette to all walls in the level."""
15
13
 
16
14
  palette = get_environment_palette(key)
@@ -22,9 +20,7 @@ def _set_ambient_palette(
22
20
  _apply_palette_to_walls(game_data, palette, force=True)
23
21
 
24
22
 
25
- def sync_ambient_palette_with_flashlights(
26
- game_data: GameData, *, force: bool = False
27
- ) -> None:
23
+ def sync_ambient_palette_with_flashlights(game_data: GameData, *, force: bool = False) -> None:
28
24
  """Sync the ambient palette with the player's flashlight inventory."""
29
25
 
30
26
  state = game_data.state
@@ -46,17 +46,14 @@ def update_footprints(game_data: GameData, config: dict[str, Any]) -> None:
46
46
  footprints = state.footprints
47
47
  if not player.in_car:
48
48
  last_pos = state.last_footprint_pos
49
- dist_sq = (
50
- (player.x - last_pos[0]) ** 2 + (player.y - last_pos[1]) ** 2
51
- if last_pos
52
- else None
53
- )
54
- if last_pos is None or (
55
- dist_sq is not None
56
- and dist_sq >= FOOTPRINT_STEP_DISTANCE * FOOTPRINT_STEP_DISTANCE
57
- ):
58
- footprints.append(Footprint(pos=(player.x, player.y), time=now))
59
- state.last_footprint_pos = (player.x, player.y)
49
+ step_distance = FOOTPRINT_STEP_DISTANCE * 0.5
50
+ step_distance_sq = step_distance * step_distance
51
+ dist_sq = (player.x - last_pos[0]) ** 2 + (player.y - last_pos[1]) ** 2 if last_pos else None
52
+ if last_pos is None or (dist_sq is not None and dist_sq >= step_distance_sq):
53
+ pos = (int(player.x), int(player.y))
54
+ footprints.append(Footprint(pos=pos, time=now, visible=state.footprint_visible_toggle))
55
+ state.footprint_visible_toggle = not state.footprint_visible_toggle
56
+ state.last_footprint_pos = pos
60
57
 
61
58
  if len(footprints) > FOOTPRINT_MAX:
62
59
  footprints = footprints[-FOOTPRINT_MAX:]
@@ -44,6 +44,7 @@ def _interaction_radius(width: float, height: float) -> float:
44
44
  """Approximate interaction reach for a humanoid and an object."""
45
45
  return HUMANOID_RADIUS + (width + height) / 4
46
46
 
47
+
47
48
  RNG = get_rng()
48
49
 
49
50
 
@@ -71,9 +72,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
71
72
 
72
73
  car_interaction_radius = _interaction_radius(CAR_WIDTH, CAR_HEIGHT)
73
74
  fuel_interaction_radius = _interaction_radius(FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT)
74
- flashlight_interaction_radius = _interaction_radius(
75
- FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT
76
- )
75
+ flashlight_interaction_radius = _interaction_radius(FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT)
77
76
  shoes_interaction_radius = _interaction_radius(SHOES_WIDTH, SHOES_HEIGHT)
78
77
 
79
78
  def _rect_center_cell(rect: pygame.Rect) -> tuple[int, int] | None:
@@ -92,14 +91,8 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
92
91
  dy = point[1] - player.y
93
92
  return dx * dx + dy * dy <= radius * radius
94
93
 
95
- def _player_near_sprite(
96
- sprite_obj: pygame.sprite.Sprite | None, radius: float
97
- ) -> bool:
98
- return bool(
99
- sprite_obj
100
- and sprite_obj.alive()
101
- and _player_near_point(sprite_obj.rect.center, radius)
102
- )
94
+ def _player_near_sprite(sprite_obj: pygame.sprite.Sprite | None, radius: float) -> bool:
95
+ return bool(sprite_obj and sprite_obj.alive() and _player_near_point(sprite_obj.rect.center, radius))
103
96
 
104
97
  def _player_near_car(car_obj: Car | None) -> bool:
105
98
  return _player_near_sprite(car_obj, car_interaction_radius)
@@ -120,9 +113,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
120
113
  for flashlight in list(flashlights):
121
114
  if not flashlight.alive():
122
115
  continue
123
- if _player_near_point(
124
- flashlight.rect.center, flashlight_interaction_radius
125
- ):
116
+ if _player_near_point(flashlight.rect.center, flashlight_interaction_radius):
126
117
  state.flashlight_count += 1
127
118
  state.hint_expires_at = 0
128
119
  state.hint_target_type = None
@@ -152,9 +143,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
152
143
  sync_ambient_palette_with_flashlights(game_data)
153
144
 
154
145
  buddies = [
155
- survivor
156
- for survivor in survivor_group
157
- if survivor.alive() and survivor.is_buddy and not survivor.rescued
146
+ survivor for survivor in survivor_group if survivor.alive() and survivor.is_buddy and not survivor.rescued
158
147
  ]
159
148
 
160
149
  # Buddy interactions (Stage 3)
@@ -164,20 +153,13 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
164
153
  continue
165
154
  buddy_on_screen = rect_visible_on_screen(camera, buddy.rect)
166
155
  if not player.in_car:
167
- dist_to_player_sq = (player.x - buddy.x) ** 2 + (
168
- player.y - buddy.y
169
- ) ** 2
170
- if (
171
- dist_to_player_sq
172
- <= SURVIVOR_APPROACH_RADIUS * SURVIVOR_APPROACH_RADIUS
173
- ):
156
+ dist_to_player_sq = (player.x - buddy.x) ** 2 + (player.y - buddy.y) ** 2
157
+ if dist_to_player_sq <= SURVIVOR_APPROACH_RADIUS * SURVIVOR_APPROACH_RADIUS:
174
158
  buddy.set_following()
175
159
  elif player.in_car and active_car and shrunk_car:
176
160
  g = pygame.sprite.Group()
177
161
  g.add(buddy)
178
- if pygame.sprite.spritecollide(
179
- shrunk_car, g, False, pygame.sprite.collide_circle
180
- ):
162
+ if pygame.sprite.spritecollide(shrunk_car, g, False, pygame.sprite.collide_circle):
181
163
  prospective_passengers = state.survivors_onboard + 1
182
164
  capacity_limit = state.survivor_capacity
183
165
  if prospective_passengers > capacity_limit:
@@ -191,9 +173,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
191
173
  buddy.kill()
192
174
  continue
193
175
 
194
- if buddy.alive() and pygame.sprite.spritecollide(
195
- buddy, zombie_group, False, pygame.sprite.collide_circle
196
- ):
176
+ if buddy.alive() and pygame.sprite.spritecollide(buddy, zombie_group, False, pygame.sprite.collide_circle):
197
177
  if buddy_on_screen:
198
178
  state.game_over_message = tr("game_over.scream")
199
179
  state.game_over = True
@@ -203,18 +183,11 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
203
183
  new_cell = RNG.choice(walkable_cells)
204
184
  buddy.teleport(_cell_center(new_cell))
205
185
  else:
206
- buddy.teleport(
207
- (game_data.level_width // 2, game_data.level_height // 2)
208
- )
186
+ buddy.teleport((game_data.level_width // 2, game_data.level_height // 2))
209
187
  buddy.following = False
210
188
 
211
189
  # Player entering an active car already under control
212
- if (
213
- not player.in_car
214
- and _player_near_car(active_car)
215
- and active_car
216
- and active_car.health > 0
217
- ):
190
+ if not player.in_car and _player_near_car(active_car) and active_car and active_car.health > 0:
218
191
  if state.has_fuel:
219
192
  player.in_car = True
220
193
  all_sprites.remove(player)
@@ -258,9 +231,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
258
231
  # Bonus: collide a parked car while driving to repair/extend capabilities
259
232
  if player.in_car and active_car and shrunk_car and waiting_cars:
260
233
  waiting_group = pygame.sprite.Group(waiting_cars)
261
- collided_waiters = pygame.sprite.spritecollide(
262
- shrunk_car, waiting_group, False, pygame.sprite.collide_rect
263
- )
234
+ collided_waiters = pygame.sprite.spritecollide(shrunk_car, waiting_group, False, pygame.sprite.collide_rect)
264
235
  if collided_waiters:
265
236
  removed_any = False
266
237
  capacity_increments = 0
@@ -288,16 +259,8 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
288
259
  if zombies_hit:
289
260
  active_car._take_damage(CAR_ZOMBIE_DAMAGE * len(zombies_hit))
290
261
 
291
- if (
292
- stage.rescue_stage
293
- and player.in_car
294
- and active_car
295
- and shrunk_car
296
- and survivor_group
297
- ):
298
- boarded = pygame.sprite.spritecollide(
299
- shrunk_car, survivor_group, True, pygame.sprite.collide_circle
300
- )
262
+ if stage.rescue_stage and player.in_car and active_car and shrunk_car and survivor_group:
263
+ boarded = pygame.sprite.spritecollide(shrunk_car, survivor_group, True, pygame.sprite.collide_circle)
301
264
  if boarded:
302
265
  state.survivors_onboard += len(boarded)
303
266
  apply_passenger_speed_penalty(game_data)
@@ -339,9 +302,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
339
302
  # Player getting caught by zombies
340
303
  if not player.in_car and player in all_sprites:
341
304
  shrunk_player = get_shrunk_sprite(player, 0.8)
342
- collisions = pygame.sprite.spritecollide(
343
- shrunk_player, zombie_group, False, pygame.sprite.collide_circle
344
- )
305
+ collisions = pygame.sprite.spritecollide(shrunk_player, zombie_group, False, pygame.sprite.collide_circle)
345
306
  if any(not zombie.carbonized for zombie in collisions):
346
307
  if not state.game_over:
347
308
  state.game_over = True
@@ -367,9 +328,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
367
328
  car_cell = _rect_center_cell(car.rect)
368
329
  if buddy_ready and car_cell is not None and car_cell in outside_cells:
369
330
  if stage.buddy_required_count > 0:
370
- state.buddy_rescued = min(
371
- stage.buddy_required_count, state.buddy_onboard
372
- )
331
+ state.buddy_rescued = min(stage.buddy_required_count, state.buddy_onboard)
373
332
  if stage.rescue_stage and state.survivors_onboard:
374
333
  state.survivors_rescued += state.survivors_onboard
375
334
  state.survivors_onboard = 0
@@ -23,7 +23,6 @@ __all__ = ["generate_level_from_blueprint", "MapGenerationError"]
23
23
  RNG = get_rng()
24
24
 
25
25
 
26
-
27
26
  def _rect_for_cell(x_idx: int, y_idx: int, cell_size: int) -> pygame.Rect:
28
27
  return pygame.Rect(
29
28
  x_idx * cell_size,
@@ -53,9 +52,7 @@ def _expand_zone_cells(
53
52
  return cells
54
53
 
55
54
 
56
- def generate_level_from_blueprint(
57
- game_data: GameData, config: dict[str, Any]
58
- ) -> dict[str, list[pygame.Rect]]:
55
+ def generate_level_from_blueprint(game_data: GameData, config: dict[str, Any]) -> dict[str, list[pygame.Rect]]:
59
56
  """Build walls/spawn candidates/outside area from a blueprint grid."""
60
57
  wall_group = game_data.groups.wall_group
61
58
  all_sprites = game_data.groups.all_sprites
@@ -69,7 +66,8 @@ def generate_level_from_blueprint(
69
66
  cols=stage.grid_cols,
70
67
  rows=stage.grid_rows,
71
68
  wall_algo=stage.wall_algorithm,
72
- pitfall_density=getattr(stage, "pitfall_density", 0.0),
69
+ pitfall_density=stage.pitfall_density,
70
+ pitfall_zones=stage.pitfall_zones,
73
71
  base_seed=game_data.state.seed,
74
72
  )
75
73
  if isinstance(blueprint_data, dict):
@@ -81,23 +79,11 @@ def generate_level_from_blueprint(
81
79
  steel_cells_raw = set()
82
80
  car_reachable_cells = set()
83
81
 
84
- steel_cells = (
85
- {(int(x), int(y)) for x, y in steel_cells_raw} if steel_enabled else set()
86
- )
82
+ steel_cells = {(int(x), int(y)) for x, y in steel_cells_raw} if steel_enabled else set()
87
83
  game_data.layout.car_walkable_cells = car_reachable_cells
88
84
  cell_size = game_data.cell_size
89
- outer_wall_cells = {
90
- (x, y)
91
- for y, row in enumerate(blueprint)
92
- for x, ch in enumerate(row)
93
- if ch == "B"
94
- }
95
- wall_cells = {
96
- (x, y)
97
- for y, row in enumerate(blueprint)
98
- for x, ch in enumerate(row)
99
- if ch in {"B", "1"}
100
- }
85
+ outer_wall_cells = {(x, y) for y, row in enumerate(blueprint) for x, ch in enumerate(row) if ch == "B"}
86
+ wall_cells = {(x, y) for y, row in enumerate(blueprint) for x, ch in enumerate(row) if ch in {"B", "1"}}
101
87
 
102
88
  def _has_wall(nx: int, ny: int) -> bool:
103
89
  if nx < 0 or ny < 0 or nx >= stage.grid_cols or ny >= stage.grid_rows:
@@ -119,7 +105,7 @@ def generate_level_from_blueprint(
119
105
  interior_max_y = stage.grid_rows - 3
120
106
  bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] = {}
121
107
  palette = get_environment_palette(game_data.state.ambient_palette_key)
122
- rubble_ratio = max(0.0, min(1.0, getattr(stage, "wall_rubble_ratio", 0.0)))
108
+ rubble_ratio = max(0.0, min(1.0, stage.wall_rubble_ratio))
123
109
 
124
110
  def add_beam_to_groups(beam: SteelBeam) -> None:
125
111
  if beam._added_to_groups:
@@ -128,16 +114,16 @@ def generate_level_from_blueprint(
128
114
  all_sprites.add(beam, layer=0)
129
115
  beam._added_to_groups = True
130
116
 
131
- def remove_wall_cell(cell: tuple[int, int]) -> None:
132
- wall_cells.discard(cell)
117
+ def remove_wall_cell(cell: tuple[int, int], *, allow_walkable: bool = True) -> None:
118
+ if cell in wall_cells:
119
+ wall_cells.discard(cell)
120
+ if allow_walkable and cell not in walkable_cells:
121
+ walkable_cells.append(cell)
133
122
  outer_wall_cells.discard(cell)
134
123
 
135
124
  for y, row in enumerate(blueprint):
136
125
  if len(row) != stage.grid_cols:
137
- raise ValueError(
138
- "Blueprint width mismatch at row "
139
- f"{y}: {len(row)} != {stage.grid_cols}"
140
- )
126
+ raise ValueError(f"Blueprint width mismatch at row {y}: {len(row)} != {stage.grid_cols}")
141
127
  for x, ch in enumerate(row):
142
128
  cell_rect = _rect_for_cell(x, y, cell_size)
143
129
  cell_has_beam = steel_enabled and (x, y) in steel_cells
@@ -180,29 +166,17 @@ def generate_level_from_blueprint(
180
166
  )
181
167
  draw_bottom_side = not _has_wall(x, y + 1)
182
168
  bevel_mask = (
183
- not _has_wall(x, y - 1)
184
- and not _has_wall(x - 1, y)
185
- and not _has_wall(x - 1, y - 1),
186
- not _has_wall(x, y - 1)
187
- and not _has_wall(x + 1, y)
188
- and not _has_wall(x + 1, y - 1),
189
- not _has_wall(x, y + 1)
190
- and not _has_wall(x + 1, y)
191
- and not _has_wall(x + 1, y + 1),
192
- not _has_wall(x, y + 1)
193
- and not _has_wall(x - 1, y)
194
- and not _has_wall(x - 1, y + 1),
169
+ not _has_wall(x, y - 1) and not _has_wall(x - 1, y) and not _has_wall(x - 1, y - 1),
170
+ not _has_wall(x, y - 1) and not _has_wall(x + 1, y) and not _has_wall(x + 1, y - 1),
171
+ not _has_wall(x, y + 1) and not _has_wall(x + 1, y) and not _has_wall(x + 1, y + 1),
172
+ not _has_wall(x, y + 1) and not _has_wall(x - 1, y) and not _has_wall(x - 1, y + 1),
195
173
  )
196
174
  if any(bevel_mask):
197
175
  bevel_corners[(x, y)] = bevel_mask
198
176
  wall_cell = (x, y)
199
177
  use_rubble = rubble_ratio > 0 and random.random() < rubble_ratio
200
178
  if use_rubble:
201
- rotation_deg = (
202
- RUBBLE_ROTATION_DEG
203
- if random.random() < 0.5
204
- else -RUBBLE_ROTATION_DEG
205
- )
179
+ rotation_deg = RUBBLE_ROTATION_DEG if random.random() < 0.5 else -RUBBLE_ROTATION_DEG
206
180
  wall = RubbleWall(
207
181
  cell_rect.x,
208
182
  cell_rect.y,
@@ -216,7 +190,7 @@ def generate_level_from_blueprint(
216
190
  on_destroy=(
217
191
  (
218
192
  lambda _w, b=beam, cell=wall_cell: (
219
- remove_wall_cell(cell),
193
+ remove_wall_cell(cell, allow_walkable=False),
220
194
  add_beam_to_groups(b),
221
195
  )
222
196
  )
@@ -238,7 +212,7 @@ def generate_level_from_blueprint(
238
212
  on_destroy=(
239
213
  (
240
214
  lambda _w, b=beam, cell=wall_cell: (
241
- remove_wall_cell(cell),
215
+ remove_wall_cell(cell, allow_walkable=False),
242
216
  add_beam_to_groups(b),
243
217
  )
244
218
  )
@@ -211,11 +211,7 @@ def update_entities(
211
211
  if game_data.state.player_wall_target_ttl <= 0:
212
212
  game_data.state.player_wall_target_cell = None
213
213
 
214
- wall_target_cell = (
215
- game_data.state.player_wall_target_cell
216
- if game_data.state.player_wall_target_ttl > 0
217
- else None
218
- )
214
+ wall_target_cell = game_data.state.player_wall_target_cell if game_data.state.player_wall_target_ttl > 0 else None
219
215
 
220
216
  update_survivors(
221
217
  game_data,
@@ -237,17 +233,11 @@ def update_entities(
237
233
  game_data.state.last_zombie_spawn_time = current_time
238
234
 
239
235
  # Update zombies
240
- target_center = (
241
- active_car.rect.center if player.in_car and active_car else player.rect.center
242
- )
236
+ target_center = active_car.rect.center if player.in_car and active_car else player.rect.center
243
237
  buddies = [
244
- survivor
245
- for survivor in survivor_group
246
- if survivor.alive() and survivor.is_buddy and not survivor.rescued
247
- ]
248
- buddies_on_screen = [
249
- buddy for buddy in buddies if rect_visible_on_screen(camera, buddy.rect)
238
+ survivor for survivor in survivor_group if survivor.alive() and survivor.is_buddy and not survivor.rescued
250
239
  ]
240
+ buddies_on_screen = [buddy for buddy in buddies if rect_visible_on_screen(camera, buddy.rect)]
251
241
 
252
242
  survivors_on_screen: list[Survivor] = []
253
243
  if stage.rescue_stage:
@@ -279,13 +269,10 @@ def update_entities(
279
269
  for idx, zombie in enumerate(zombies_sorted):
280
270
  target = target_center
281
271
  if buddies_on_screen:
282
- dist_to_target_sq = (target_center[0] - zombie.x) ** 2 + (
283
- target_center[1] - zombie.y
284
- ) ** 2
272
+ dist_to_target_sq = (target_center[0] - zombie.x) ** 2 + (target_center[1] - zombie.y) ** 2
285
273
  nearest_buddy = min(
286
274
  buddies_on_screen,
287
- key=lambda buddy: (buddy.rect.centerx - zombie.x) ** 2
288
- + (buddy.rect.centery - zombie.y) ** 2,
275
+ key=lambda buddy: (buddy.rect.centerx - zombie.x) ** 2 + (buddy.rect.centery - zombie.y) ** 2,
289
276
  )
290
277
  dist_to_buddy_sq = (nearest_buddy.rect.centerx - zombie.x) ** 2 + (
291
278
  nearest_buddy.rect.centery - zombie.y
@@ -305,8 +292,7 @@ def update_entities(
305
292
  if candidate_positions:
306
293
  target = min(
307
294
  candidate_positions,
308
- key=lambda pos: (pos[0] - zombie.x) ** 2
309
- + (pos[1] - zombie.y) ** 2,
295
+ key=lambda pos: (pos[0] - zombie.x) ** 2 + (pos[1] - zombie.y) ** 2,
310
296
  )
311
297
  nearby_candidates = _nearby_zombies(idx)
312
298
  zombie_search_radius = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius + 120