zombie-escape 1.8.0__py3-none-any.whl → 1.10.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.
@@ -34,6 +34,9 @@ ZOMBIE_SEPARATION_DISTANCE = ZOMBIE_RADIUS * 2.2
34
34
  ZOMBIE_AGING_DURATION_FRAMES = FPS * 60 * 6 # ~6 minutes at target framerate
35
35
  ZOMBIE_AGING_MIN_SPEED_RATIO = 0.3
36
36
  ZOMBIE_TRACKER_SCENT_RADIUS = 70
37
+ ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER = 2
38
+ ZOMBIE_TRACKER_SCENT_TOP_K = 3
39
+ ZOMBIE_TRACKER_SCAN_INTERVAL_MS = int(1000 * 30 / FPS)
37
40
  ZOMBIE_TRACKER_WANDER_INTERVAL_MS = 2500
38
41
  ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE = 24
39
42
  ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG = 45
@@ -81,6 +84,9 @@ __all__ = [
81
84
  "ZOMBIE_AGING_DURATION_FRAMES",
82
85
  "ZOMBIE_AGING_MIN_SPEED_RATIO",
83
86
  "ZOMBIE_TRACKER_SCENT_RADIUS",
87
+ "ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER",
88
+ "ZOMBIE_TRACKER_SCENT_TOP_K",
89
+ "ZOMBIE_TRACKER_SCAN_INTERVAL_MS",
84
90
  "ZOMBIE_TRACKER_WANDER_INTERVAL_MS",
85
91
  "ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE",
86
92
  "ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG",
@@ -21,7 +21,7 @@ from .spawn import (
21
21
  spawn_waiting_car,
22
22
  spawn_weighted_zombie,
23
23
  )
24
- from .state import carbonize_outdoor_zombies, initialize_game_state, update_survival_timer
24
+ from .state import carbonize_outdoor_zombies, initialize_game_state, update_endurance_timer
25
25
  from .survivors import (
26
26
  add_survivor_message,
27
27
  apply_passenger_speed_penalty,
@@ -71,7 +71,7 @@ __all__ = [
71
71
  "initialize_game_state",
72
72
  "setup_player_and_cars",
73
73
  "spawn_initial_zombies",
74
- "update_survival_timer",
74
+ "update_endurance_timer",
75
75
  "carbonize_outdoor_zombies",
76
76
  "process_player_input",
77
77
  "update_entities",
@@ -15,7 +15,7 @@ FOOTPRINT_MAX = 320
15
15
 
16
16
  # --- Zombie settings ---
17
17
  MAX_ZOMBIES = 400
18
- ZOMBIE_SPAWN_PLAYER_BUFFER = 140
18
+ ZOMBIE_SPAWN_PLAYER_BUFFER = 230
19
19
  ZOMBIE_TRACKER_AGING_DURATION_FRAMES = ZOMBIE_AGING_DURATION_FRAMES
20
20
 
21
21
  # --- Car and fuel settings ---
@@ -5,7 +5,7 @@ from typing import Any
5
5
  import pygame
6
6
 
7
7
  from .constants import FOOTPRINT_MAX, FOOTPRINT_STEP_DISTANCE
8
- from ..models import GameData
8
+ from ..models import Footprint, GameData
9
9
 
10
10
 
11
11
  def get_shrunk_sprite(
@@ -51,7 +51,7 @@ def update_footprints(game_data: GameData, config: dict[str, Any]) -> None:
51
51
  dist_sq is not None
52
52
  and dist_sq >= FOOTPRINT_STEP_DISTANCE * FOOTPRINT_STEP_DISTANCE
53
53
  ):
54
- footprints.append({"pos": (player.x, player.y), "time": now})
54
+ footprints.append(Footprint(pos=(player.x, player.y), time=now))
55
55
  state.last_footprint_pos = (player.x, player.y)
56
56
 
57
57
  if len(footprints) > FOOTPRINT_MAX:
@@ -45,9 +45,7 @@ def _interaction_radius(width: float, height: float) -> float:
45
45
  RNG = get_rng()
46
46
 
47
47
 
48
- def check_interactions(
49
- game_data: GameData, config: dict[str, Any]
50
- ) -> pygame.sprite.Sprite | None:
48
+ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
51
49
  """Check and handle interactions between entities."""
52
50
  player = game_data.player
53
51
  assert player is not None
@@ -193,7 +191,7 @@ def check_interactions(
193
191
  state.hint_target_type = None
194
192
  print("Player entered car!")
195
193
  else:
196
- if not stage.survival_stage:
194
+ if not stage.endurance_stage:
197
195
  now_ms = state.elapsed_play_ms
198
196
  state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
199
197
  state.hint_target_type = "fuel"
@@ -221,7 +219,7 @@ def check_interactions(
221
219
  maintain_waiting_car_supply(game_data)
222
220
  print("Player claimed a waiting car!")
223
221
  else:
224
- if not stage.survival_stage:
222
+ if not stage.endurance_stage:
225
223
  now_ms = state.elapsed_play_ms
226
224
  state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
227
225
  state.hint_target_type = "fuel"
@@ -321,7 +319,7 @@ def check_interactions(
321
319
 
322
320
  # Player escaping on foot after dawn (Stage 5)
323
321
  if (
324
- stage.survival_stage
322
+ stage.endurance_stage
325
323
  and state.dawn_ready
326
324
  and not player.in_car
327
325
  and outside_rects
@@ -344,11 +342,7 @@ def check_interactions(
344
342
  if stage.rescue_stage and state.survivors_onboard:
345
343
  state.survivors_rescued += state.survivors_onboard
346
344
  state.survivors_onboard = 0
347
- state.next_overload_check_ms = 0
348
345
  apply_passenger_speed_penalty(game_data)
349
346
  state.game_won = True
350
347
 
351
- # Return fog of view target
352
- if not state.game_over and not state.game_won:
353
- return car if player.in_car and car and car.alive() else player
354
348
  return None
@@ -23,6 +23,26 @@ def _rect_for_cell(x_idx: int, y_idx: int, cell_size: int) -> pygame.Rect:
23
23
  )
24
24
 
25
25
 
26
+ def _expand_zone_cells(
27
+ zones: list[tuple[int, int, int, int]],
28
+ *,
29
+ grid_cols: int,
30
+ grid_rows: int,
31
+ ) -> set[tuple[int, int]]:
32
+ cells: set[tuple[int, int]] = set()
33
+ for col, row, width, height in zones:
34
+ if width <= 0 or height <= 0:
35
+ continue
36
+ start_x = max(0, col)
37
+ start_y = max(0, row)
38
+ end_x = min(grid_cols, col + width)
39
+ end_y = min(grid_rows, row + height)
40
+ for y in range(start_y, end_y):
41
+ for x in range(start_x, end_x):
42
+ cells.add((x, y))
43
+ return cells
44
+
45
+
26
46
  def generate_level_from_blueprint(
27
47
  game_data: GameData, config: dict[str, Any]
28
48
  ) -> dict[str, list[pygame.Rect]]:
@@ -78,12 +98,16 @@ def generate_level_from_blueprint(
78
98
  palette = get_environment_palette(game_data.state.ambient_palette_key)
79
99
 
80
100
  def add_beam_to_groups(beam: SteelBeam) -> None:
81
- if getattr(beam, "_added_to_groups", False):
101
+ if beam._added_to_groups:
82
102
  return
83
103
  wall_group.add(beam)
84
104
  all_sprites.add(beam, layer=0)
85
105
  beam._added_to_groups = True
86
106
 
107
+ def remove_wall_cell(cell: tuple[int, int]) -> None:
108
+ wall_cells.discard(cell)
109
+ outer_wall_cells.discard(cell)
110
+
87
111
  for y, row in enumerate(blueprint):
88
112
  if len(row) != stage.grid_cols:
89
113
  raise ValueError(
@@ -98,6 +122,7 @@ def generate_level_from_blueprint(
98
122
  continue
99
123
  if ch == "B":
100
124
  draw_bottom_side = not _has_wall(x, y + 1)
125
+ wall_cell = (x, y)
101
126
  wall = Wall(
102
127
  cell_rect.x,
103
128
  cell_rect.y,
@@ -108,6 +133,7 @@ def generate_level_from_blueprint(
108
133
  palette_category="outer_wall",
109
134
  bevel_depth=0,
110
135
  draw_bottom_side=draw_bottom_side,
136
+ on_destroy=(lambda _w, cell=wall_cell: remove_wall_cell(cell)),
111
137
  )
112
138
  wall_group.add(wall)
113
139
  all_sprites.add(wall, layer=0)
@@ -142,6 +168,7 @@ def generate_level_from_blueprint(
142
168
  )
143
169
  if any(bevel_mask):
144
170
  bevel_corners[(x, y)] = bevel_mask
171
+ wall_cell = (x, y)
145
172
  wall = Wall(
146
173
  cell_rect.x,
147
174
  cell_rect.y,
@@ -152,9 +179,11 @@ def generate_level_from_blueprint(
152
179
  palette_category="inner_wall",
153
180
  bevel_mask=bevel_mask,
154
181
  draw_bottom_side=draw_bottom_side,
155
- on_destroy=(lambda _w, b=beam: add_beam_to_groups(b))
156
- if beam
157
- else None,
182
+ on_destroy=(
183
+ (lambda _w, b=beam, cell=wall_cell: (remove_wall_cell(cell), add_beam_to_groups(b)))
184
+ if beam
185
+ else (lambda _w, cell=wall_cell: remove_wall_cell(cell))
186
+ ),
158
187
  )
159
188
  wall_group.add(wall)
160
189
  all_sprites.add(wall, layer=0)
@@ -185,6 +214,11 @@ def generate_level_from_blueprint(
185
214
  game_data.layout.walkable_cells = walkable_cells
186
215
  game_data.layout.outer_wall_cells = outer_wall_cells
187
216
  game_data.layout.wall_cells = wall_cells
217
+ game_data.layout.fall_spawn_cells = _expand_zone_cells(
218
+ stage.fall_spawn_zones,
219
+ grid_cols=stage.grid_cols,
220
+ grid_rows=stage.grid_rows,
221
+ )
188
222
  game_data.layout.bevel_corners = bevel_corners
189
223
 
190
224
  return {
@@ -10,20 +10,17 @@ from ..entities import (
10
10
  Player,
11
11
  Survivor,
12
12
  Wall,
13
- WallIndex,
14
13
  Zombie,
15
- apply_tile_edge_nudge,
16
- walls_for_radius,
17
14
  )
18
15
  from ..entities_constants import (
19
- CAR_SPEED,
20
16
  PLAYER_SPEED,
21
17
  ZOMBIE_SEPARATION_DISTANCE,
22
18
  ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
23
19
  )
24
20
  from ..models import GameData
21
+ from ..world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
25
22
  from .constants import MAX_ZOMBIES
26
- from .spawn import spawn_weighted_zombie
23
+ from .spawn import spawn_weighted_zombie, update_falling_zombies
27
24
  from .survivors import update_survivors
28
25
  from .utils import rect_visible_on_screen
29
26
 
@@ -50,7 +47,7 @@ def process_player_input(
50
47
  player_dx, player_dy, car_dx, car_dy = 0, 0, 0, 0
51
48
 
52
49
  if player.in_car and car and car.alive():
53
- target_speed = getattr(car, "speed", CAR_SPEED)
50
+ target_speed = car.speed
54
51
  move_len = math.hypot(dx_input, dy_input)
55
52
  if move_len > 0:
56
53
  car_dx, car_dy = (
@@ -156,11 +153,12 @@ def update_entities(
156
153
  camera.update(target_for_camera)
157
154
 
158
155
  update_survivors(game_data, wall_index=wall_index)
156
+ update_falling_zombies(game_data, config)
159
157
 
160
158
  # Spawn new zombies if needed
161
159
  current_time = pygame.time.get_ticks()
162
160
  spawn_interval = max(1, stage.spawn_interval_ms)
163
- spawn_blocked = stage.survival_stage and game_data.state.dawn_ready
161
+ spawn_blocked = stage.endurance_stage and game_data.state.dawn_ready
164
162
  if (
165
163
  len(zombie_group) < MAX_ZOMBIES
166
164
  and not spawn_blocked
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Mapping, Sequence
3
+ from typing import Any, Callable, Literal, Mapping, Sequence
4
4
 
5
5
  import pygame
6
6
 
@@ -16,13 +16,15 @@ from ..entities import (
16
16
  )
17
17
  from ..entities_constants import (
18
18
  FAST_ZOMBIE_BASE_SPEED,
19
+ FOV_RADIUS,
19
20
  PLAYER_SPEED,
20
21
  ZOMBIE_AGING_DURATION_FRAMES,
21
22
  ZOMBIE_SPEED,
22
23
  )
23
24
  from ..gameplay_constants import DEFAULT_FLASHLIGHT_SPAWN_COUNT
24
25
  from ..level_constants import DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TILE_SIZE
25
- from ..models import GameData, Stage
26
+ from ..models import DustRing, FallingZombie, GameData, Stage
27
+ from ..render_constants import FLASHLIGHT_FOG_SCALE_STEP, FOG_RADIUS_SCALE
26
28
  from ..rng import get_rng
27
29
  from .constants import (
28
30
  MAX_ZOMBIES,
@@ -38,6 +40,8 @@ from .utils import (
38
40
 
39
41
  RNG = get_rng()
40
42
 
43
+ FallScheduleResult = Literal["scheduled", "no_position", "blocked", "no_player"]
44
+
41
45
  __all__ = [
42
46
  "place_new_car",
43
47
  "place_fuel_can",
@@ -49,13 +53,14 @@ __all__ = [
49
53
  "spawn_waiting_car",
50
54
  "maintain_waiting_car_supply",
51
55
  "nearest_waiting_car",
56
+ "update_falling_zombies",
52
57
  "spawn_exterior_zombie",
53
58
  "spawn_weighted_zombie",
54
59
  ]
55
60
 
56
61
 
57
62
  def _car_appearance_for_stage(stage: Stage | None) -> str:
58
- return "disabled" if stage and stage.survival_stage else "default"
63
+ return "disabled" if stage and stage.endurance_stage else "default"
59
64
 
60
65
 
61
66
  def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
@@ -87,6 +92,140 @@ def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
87
92
  return False, True
88
93
 
89
94
 
95
+ def _fov_radius_for_flashlights(flashlight_count: int) -> float:
96
+ count = max(0, int(flashlight_count))
97
+ scale = FOG_RADIUS_SCALE + max(0.0, FLASHLIGHT_FOG_SCALE_STEP) * count
98
+ return FOV_RADIUS * scale
99
+
100
+
101
+ def _is_spawn_position_clear(
102
+ game_data: GameData,
103
+ candidate: Zombie,
104
+ *,
105
+ allow_player_overlap: bool = False,
106
+ ) -> bool:
107
+ wall_group = game_data.groups.wall_group
108
+ if spritecollideany_walls(candidate, wall_group):
109
+ return False
110
+
111
+ spawn_rect = candidate.rect
112
+ player = game_data.player
113
+ if not allow_player_overlap and player and spawn_rect.colliderect(player.rect):
114
+ return False
115
+ car = game_data.car
116
+ if car and car.alive() and spawn_rect.colliderect(car.rect):
117
+ return False
118
+ for parked in game_data.waiting_cars:
119
+ if parked.alive() and spawn_rect.colliderect(parked.rect):
120
+ return False
121
+ for survivor in game_data.groups.survivor_group:
122
+ if survivor.alive() and spawn_rect.colliderect(survivor.rect):
123
+ return False
124
+ for zombie in game_data.groups.zombie_group:
125
+ if zombie.alive() and spawn_rect.colliderect(zombie.rect):
126
+ return False
127
+ return True
128
+
129
+
130
+ def _pick_fall_spawn_position(
131
+ game_data: GameData,
132
+ *,
133
+ min_distance: float,
134
+ attempts: int = 10,
135
+ is_clear: Callable[[tuple[int, int]], bool] | None = None,
136
+ ) -> tuple[int, int] | None:
137
+ player = game_data.player
138
+ if not player:
139
+ return None
140
+ fall_spawn_cells = game_data.layout.fall_spawn_cells
141
+ if not fall_spawn_cells:
142
+ return None
143
+ car = game_data.car
144
+ target_sprite = car if player.in_car and car and car.alive() else player
145
+ target_center = target_sprite.rect.center
146
+ cell_size = game_data.cell_size
147
+ fov_radius = _fov_radius_for_flashlights(game_data.state.flashlight_count)
148
+ min_dist_sq = min_distance * min_distance
149
+ max_dist_sq = fov_radius * fov_radius
150
+ wall_cells = game_data.layout.wall_cells
151
+
152
+ candidates: list[tuple[int, int]] = []
153
+ for cell_x, cell_y in fall_spawn_cells:
154
+ if (cell_x, cell_y) in wall_cells:
155
+ continue
156
+ pos = (
157
+ int(cell_x * cell_size + cell_size // 2),
158
+ int(cell_y * cell_size + cell_size // 2),
159
+ )
160
+ dx = pos[0] - target_center[0]
161
+ dy = pos[1] - target_center[1]
162
+ dist_sq = dx * dx + dy * dy
163
+ if dist_sq < min_dist_sq or dist_sq > max_dist_sq:
164
+ continue
165
+ candidates.append(pos)
166
+
167
+ if not candidates:
168
+ return None
169
+
170
+ RNG.shuffle(candidates)
171
+ for pos in candidates[: max(1, min(attempts, len(candidates)))]:
172
+ if is_clear is not None and not is_clear(pos):
173
+ continue
174
+ return pos
175
+ return None
176
+
177
+
178
+ def _schedule_falling_zombie(
179
+ game_data: GameData,
180
+ config: dict[str, Any],
181
+ *,
182
+ allow_carry: bool = True,
183
+ ) -> FallScheduleResult:
184
+ player = game_data.player
185
+ if not player:
186
+ return "no_player"
187
+ state = game_data.state
188
+ zombie_group = game_data.groups.zombie_group
189
+ if len(zombie_group) + len(state.falling_zombies) >= MAX_ZOMBIES:
190
+ return "blocked"
191
+ min_distance = game_data.stage.tile_size * 0.5
192
+ tracker, wall_follower = _pick_zombie_variant(game_data.stage)
193
+
194
+ def _candidate_clear(pos: tuple[int, int]) -> bool:
195
+ candidate = _create_zombie(
196
+ config,
197
+ start_pos=pos,
198
+ stage=game_data.stage,
199
+ tracker=tracker,
200
+ wall_follower=wall_follower,
201
+ )
202
+ return _is_spawn_position_clear(game_data, candidate)
203
+
204
+ spawn_pos = _pick_fall_spawn_position(
205
+ game_data,
206
+ min_distance=min_distance,
207
+ is_clear=_candidate_clear,
208
+ )
209
+ if spawn_pos is None:
210
+ if allow_carry:
211
+ state.falling_spawn_carry += 1
212
+ return "no_position"
213
+ start_offset = game_data.stage.tile_size * 0.7
214
+ start_pos = (int(spawn_pos[0]), int(spawn_pos[1] - start_offset))
215
+ fall = FallingZombie(
216
+ start_pos=start_pos,
217
+ target_pos=(int(spawn_pos[0]), int(spawn_pos[1])),
218
+ started_at_ms=pygame.time.get_ticks(),
219
+ pre_fx_ms=350,
220
+ fall_duration_ms=450,
221
+ dust_duration_ms=220,
222
+ tracker=tracker,
223
+ wall_follower=wall_follower,
224
+ )
225
+ state.falling_zombies.append(fall)
226
+ return "scheduled"
227
+
228
+
90
229
  def _create_zombie(
91
230
  config: dict[str, Any],
92
231
  *,
@@ -435,6 +574,34 @@ def spawn_initial_zombies(
435
574
  if not spawn_cells:
436
575
  return
437
576
 
577
+ if game_data.stage.id == "debug_tracker":
578
+ player_pos = player.rect.center
579
+ min_dist_sq = 100 * 100
580
+ max_dist_sq = 240 * 240
581
+ candidates = [
582
+ cell
583
+ for cell in spawn_cells
584
+ if min_dist_sq
585
+ <= (cell.centerx - player_pos[0]) ** 2 + (cell.centery - player_pos[1]) ** 2
586
+ <= max_dist_sq
587
+ ]
588
+ if not candidates:
589
+ candidates = spawn_cells
590
+ candidate = RNG.choice(candidates)
591
+ tentative = _create_zombie(
592
+ config,
593
+ start_pos=candidate.center,
594
+ stage=game_data.stage,
595
+ tracker=True,
596
+ wall_follower=False,
597
+ )
598
+ if not spritecollideany_walls(tentative, wall_group):
599
+ zombie_group.add(tentative)
600
+ all_sprites.add(tentative, layer=1)
601
+ interval = max(1, game_data.stage.spawn_interval_ms)
602
+ game_data.state.last_zombie_spawn_time = pygame.time.get_ticks() - interval
603
+ return
604
+
438
605
  spawn_rate = max(0.0, game_data.stage.initial_interior_spawn_rate)
439
606
  positions = find_interior_spawn_positions(
440
607
  spawn_cells,
@@ -557,6 +724,7 @@ def _spawn_nearby_zombie(
557
724
  game_data.layout.walkable_cells,
558
725
  player=player,
559
726
  camera=camera,
727
+ min_player_dist=ZOMBIE_SPAWN_PLAYER_BUFFER,
560
728
  attempts=50,
561
729
  )
562
730
  new_zombie = _create_zombie(
@@ -596,6 +764,43 @@ def spawn_exterior_zombie(
596
764
  return new_zombie
597
765
 
598
766
 
767
+ def update_falling_zombies(game_data: GameData, config: dict[str, Any]) -> None:
768
+ state = game_data.state
769
+ if not state.falling_zombies:
770
+ return
771
+ now = pygame.time.get_ticks()
772
+ zombie_group = game_data.groups.zombie_group
773
+ all_sprites = game_data.groups.all_sprites
774
+ for fall in list(state.falling_zombies):
775
+ fall_start = fall.started_at_ms + fall.pre_fx_ms
776
+ impact_at = fall_start + fall.fall_duration_ms
777
+ spawn_at = impact_at + fall.dust_duration_ms
778
+ if now >= impact_at and not fall.dust_started:
779
+ state.dust_rings.append(
780
+ DustRing(
781
+ pos=fall.target_pos,
782
+ started_at_ms=impact_at,
783
+ duration_ms=fall.dust_duration_ms,
784
+ )
785
+ )
786
+ fall.dust_started = True
787
+ if now < spawn_at:
788
+ continue
789
+ if len(zombie_group) >= MAX_ZOMBIES:
790
+ state.falling_zombies.remove(fall)
791
+ continue
792
+ candidate = _create_zombie(
793
+ config,
794
+ start_pos=fall.target_pos,
795
+ stage=game_data.stage,
796
+ tracker=fall.tracker,
797
+ wall_follower=fall.wall_follower,
798
+ )
799
+ zombie_group.add(candidate)
800
+ all_sprites.add(candidate, layer=1)
801
+ state.falling_zombies.remove(fall)
802
+
803
+
599
804
  def spawn_weighted_zombie(
600
805
  game_data: GameData,
601
806
  config: dict[str, Any],
@@ -603,23 +808,51 @@ def spawn_weighted_zombie(
603
808
  """Spawn a zombie according to the stage's interior/exterior mix."""
604
809
  stage = game_data.stage
605
810
 
606
- def _spawn(choice: str) -> bool:
607
- if choice == "interior":
608
- return _spawn_nearby_zombie(game_data, config) is not None
811
+ def _spawn_interior() -> bool:
812
+ return _spawn_nearby_zombie(game_data, config) is not None
813
+
814
+ def _spawn_exterior() -> bool:
609
815
  return spawn_exterior_zombie(game_data, config) is not None
610
816
 
817
+ def _spawn_fall() -> FallScheduleResult:
818
+ result = _schedule_falling_zombie(game_data, config)
819
+ if result != "scheduled":
820
+ return result
821
+ state = game_data.state
822
+ if state.falling_spawn_carry > 0:
823
+ extra = _schedule_falling_zombie(
824
+ game_data,
825
+ config,
826
+ allow_carry=False,
827
+ )
828
+ if extra == "scheduled":
829
+ state.falling_spawn_carry = max(0, state.falling_spawn_carry - 1)
830
+ return "scheduled"
831
+
611
832
  interior_weight = max(0.0, stage.interior_spawn_weight)
612
833
  exterior_weight = max(0.0, stage.exterior_spawn_weight)
613
- total_weight = interior_weight + exterior_weight
834
+ fall_weight = max(0.0, getattr(stage, "interior_fall_spawn_weight", 0.0))
835
+ total_weight = interior_weight + exterior_weight + fall_weight
614
836
  if total_weight <= 0:
615
837
  # Fall back to exterior spawns if weights are unset or invalid.
616
- return _spawn("exterior")
838
+ return _spawn_exterior()
617
839
 
618
840
  pick = RNG.uniform(0, total_weight)
619
841
  if pick <= interior_weight:
620
- if _spawn("interior"):
842
+ if _spawn_interior():
843
+ return True
844
+ fall_result = _spawn_fall()
845
+ if fall_result == "scheduled":
621
846
  return True
622
- return _spawn("exterior")
623
- if _spawn("exterior"):
847
+ return False
848
+ if pick <= interior_weight + fall_weight:
849
+ fall_result = _spawn_fall()
850
+ if fall_result == "scheduled":
851
+ return True
852
+ return False
853
+ if _spawn_exterior():
854
+ return True
855
+ fall_result = _spawn_fall()
856
+ if fall_result == "scheduled":
624
857
  return True
625
- return _spawn("interior")
858
+ return False
@@ -14,7 +14,7 @@ from .ambient import _set_ambient_palette
14
14
  def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
15
15
  """Initialize and return the base game state objects."""
16
16
  starts_with_fuel = not stage.requires_fuel
17
- if stage.survival_stage:
17
+ if stage.endurance_stage:
18
18
  starts_with_fuel = False
19
19
  starts_with_flashlight = False
20
20
  initial_flashlights = 1 if starts_with_flashlight else 0
@@ -42,14 +42,17 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
42
42
  survivor_messages=[],
43
43
  survivor_capacity=SURVIVOR_MAX_SAFE_PASSENGERS,
44
44
  seed=None,
45
- survival_elapsed_ms=0,
46
- survival_goal_ms=max(0, stage.survival_goal_ms),
45
+ endurance_elapsed_ms=0,
46
+ endurance_goal_ms=max(0, stage.endurance_goal_ms),
47
47
  dawn_ready=False,
48
48
  dawn_prompt_at=None,
49
49
  time_accel_active=False,
50
50
  last_zombie_spawn_time=0,
51
51
  dawn_carbonized=False,
52
52
  debug_mode=False,
53
+ falling_zombies=[],
54
+ falling_spawn_carry=0,
55
+ dust_rings=[],
53
56
  )
54
57
 
55
58
  # Create sprite groups
@@ -84,6 +87,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
84
87
  walkable_cells=[],
85
88
  outer_wall_cells=set(),
86
89
  wall_cells=set(),
90
+ fall_spawn_cells=set(),
87
91
  bevel_corners={},
88
92
  ),
89
93
  fog={
@@ -108,29 +112,26 @@ def carbonize_outdoor_zombies(game_data: GameData) -> None:
108
112
  if not group:
109
113
  return
110
114
  for zombie in list(group):
111
- alive = getattr(zombie, "alive", lambda: False)
112
- if not alive():
115
+ if not zombie.alive():
113
116
  continue
114
117
  center = zombie.rect.center
115
118
  if any(rect_obj.collidepoint(center) for rect_obj in outside_rects):
116
- carbonize = getattr(zombie, "carbonize", None)
117
- if carbonize:
118
- carbonize()
119
+ zombie.carbonize()
119
120
 
120
121
 
121
- def update_survival_timer(game_data: GameData, dt_ms: int) -> None:
122
- """Advance the survival countdown and trigger dawn handoff."""
122
+ def update_endurance_timer(game_data: GameData, dt_ms: int) -> None:
123
+ """Advance the endurance countdown and trigger dawn handoff."""
123
124
  stage = game_data.stage
124
125
  state = game_data.state
125
- if not stage.survival_stage:
126
+ if not stage.endurance_stage:
126
127
  return
127
- if state.survival_goal_ms <= 0 or dt_ms <= 0:
128
+ if state.endurance_goal_ms <= 0 or dt_ms <= 0:
128
129
  return
129
- state.survival_elapsed_ms = min(
130
- state.survival_goal_ms,
131
- state.survival_elapsed_ms + dt_ms,
130
+ state.endurance_elapsed_ms = min(
131
+ state.endurance_goal_ms,
132
+ state.endurance_elapsed_ms + dt_ms,
132
133
  )
133
- if not state.dawn_ready and state.survival_elapsed_ms >= state.survival_goal_ms:
134
+ if not state.dawn_ready and state.endurance_elapsed_ms >= state.endurance_goal_ms:
134
135
  state.dawn_ready = True
135
136
  state.dawn_prompt_at = pygame.time.get_ticks()
136
137
  _set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=True)