zombie-escape 1.7.1__py3-none-any.whl → 1.10.0__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 (36) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/colors.py +41 -8
  3. zombie_escape/entities.py +376 -306
  4. zombie_escape/entities_constants.py +6 -0
  5. zombie_escape/gameplay/__init__.py +2 -2
  6. zombie_escape/gameplay/constants.py +1 -7
  7. zombie_escape/gameplay/footprints.py +2 -2
  8. zombie_escape/gameplay/interactions.py +4 -10
  9. zombie_escape/gameplay/layout.py +43 -4
  10. zombie_escape/gameplay/movement.py +45 -7
  11. zombie_escape/gameplay/spawn.py +283 -43
  12. zombie_escape/gameplay/state.py +19 -16
  13. zombie_escape/gameplay/survivors.py +47 -15
  14. zombie_escape/gameplay/utils.py +19 -1
  15. zombie_escape/input_utils.py +167 -0
  16. zombie_escape/level_blueprints.py +28 -0
  17. zombie_escape/locales/ui.en.json +55 -11
  18. zombie_escape/locales/ui.ja.json +54 -10
  19. zombie_escape/localization.py +28 -0
  20. zombie_escape/models.py +54 -7
  21. zombie_escape/render.py +704 -267
  22. zombie_escape/render_constants.py +12 -0
  23. zombie_escape/screens/__init__.py +1 -0
  24. zombie_escape/screens/game_over.py +8 -4
  25. zombie_escape/screens/gameplay.py +88 -41
  26. zombie_escape/screens/settings.py +124 -13
  27. zombie_escape/screens/title.py +111 -0
  28. zombie_escape/stage_constants.py +116 -3
  29. zombie_escape/world_grid.py +134 -0
  30. zombie_escape/zombie_escape.py +68 -61
  31. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/METADATA +11 -3
  32. zombie_escape-1.10.0.dist-info/RECORD +47 -0
  33. zombie_escape-1.7.1.dist-info/RECORD +0 -45
  34. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/WHEEL +0 -0
  35. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/entry_points.txt +0 -0
  36. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -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,177 @@ __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"
64
+
65
+
66
+ def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
67
+ normal_ratio = 1.0
68
+ tracker_ratio = 0.0
69
+ wall_follower_ratio = 0.0
70
+ if stage is not None:
71
+ normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
72
+ tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
73
+ wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
74
+ if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
75
+ normal_ratio = 1.0
76
+ tracker_ratio = 0.0
77
+ wall_follower_ratio = 0.0
78
+ if (
79
+ normal_ratio == 1.0
80
+ and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
81
+ and tracker_ratio + wall_follower_ratio <= 1.0
82
+ ):
83
+ normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
84
+ total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
85
+ if total_ratio <= 0:
86
+ return False, False
87
+ pick = RNG.random() * total_ratio
88
+ if pick < normal_ratio:
89
+ return False, False
90
+ if pick < normal_ratio + tracker_ratio:
91
+ return True, False
92
+ return False, True
93
+
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"
59
227
 
60
228
 
61
229
  def _create_zombie(
@@ -75,44 +243,19 @@ def _create_zombie(
75
243
  else:
76
244
  base_speed = ZOMBIE_SPEED
77
245
  base_speed = min(base_speed, PLAYER_SPEED - 0.05)
78
- normal_ratio = 1.0
79
- tracker_ratio = 0.0
80
- wall_follower_ratio = 0.0
81
246
  if stage is not None:
82
- normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
83
- tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
84
- wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
85
- if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
86
- normal_ratio = 1.0
87
- tracker_ratio = 0.0
88
- wall_follower_ratio = 0.0
89
- if (
90
- normal_ratio == 1.0
91
- and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
92
- and tracker_ratio + wall_follower_ratio <= 1.0
93
- ):
94
- normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
95
247
  aging_duration_frames = max(
96
248
  1.0,
97
249
  float(stage.zombie_aging_duration_frames),
98
250
  )
99
251
  else:
100
252
  aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
101
- picked_tracker = False
102
- picked_wall_follower = False
103
- total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
104
- if total_ratio > 0:
105
- pick = RNG.random() * total_ratio
106
- if pick < normal_ratio:
107
- pass
108
- elif pick < normal_ratio + tracker_ratio:
109
- picked_tracker = True
110
- else:
111
- picked_wall_follower = True
112
- if tracker is None:
113
- tracker = picked_tracker
114
- if wall_follower is None:
115
- wall_follower = picked_wall_follower
253
+ if tracker is None or wall_follower is None:
254
+ picked_tracker, picked_wall_follower = _pick_zombie_variant(stage)
255
+ if tracker is None:
256
+ tracker = picked_tracker
257
+ if wall_follower is None:
258
+ wall_follower = picked_wall_follower
116
259
  if tracker:
117
260
  wall_follower = False
118
261
  if tracker:
@@ -431,6 +574,34 @@ def spawn_initial_zombies(
431
574
  if not spawn_cells:
432
575
  return
433
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
+
434
605
  spawn_rate = max(0.0, game_data.stage.initial_interior_spawn_rate)
435
606
  positions = find_interior_spawn_positions(
436
607
  spawn_cells,
@@ -440,10 +611,13 @@ def spawn_initial_zombies(
440
611
  )
441
612
 
442
613
  for pos in positions:
614
+ tracker, wall_follower = _pick_zombie_variant(game_data.stage)
443
615
  tentative = _create_zombie(
444
616
  config,
445
617
  start_pos=pos,
446
618
  stage=game_data.stage,
619
+ tracker=tracker,
620
+ wall_follower=wall_follower,
447
621
  )
448
622
  if spritecollideany_walls(tentative, wall_group):
449
623
  continue
@@ -550,6 +724,7 @@ def _spawn_nearby_zombie(
550
724
  game_data.layout.walkable_cells,
551
725
  player=player,
552
726
  camera=camera,
727
+ min_player_dist=ZOMBIE_SPAWN_PLAYER_BUFFER,
553
728
  attempts=50,
554
729
  )
555
730
  new_zombie = _create_zombie(
@@ -589,6 +764,43 @@ def spawn_exterior_zombie(
589
764
  return new_zombie
590
765
 
591
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
+
592
804
  def spawn_weighted_zombie(
593
805
  game_data: GameData,
594
806
  config: dict[str, Any],
@@ -596,23 +808,51 @@ def spawn_weighted_zombie(
596
808
  """Spawn a zombie according to the stage's interior/exterior mix."""
597
809
  stage = game_data.stage
598
810
 
599
- def _spawn(choice: str) -> bool:
600
- if choice == "interior":
601
- 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:
602
815
  return spawn_exterior_zombie(game_data, config) is not None
603
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
+
604
832
  interior_weight = max(0.0, stage.interior_spawn_weight)
605
833
  exterior_weight = max(0.0, stage.exterior_spawn_weight)
606
- 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
607
836
  if total_weight <= 0:
608
837
  # Fall back to exterior spawns if weights are unset or invalid.
609
- return _spawn("exterior")
838
+ return _spawn_exterior()
610
839
 
611
840
  pick = RNG.uniform(0, total_weight)
612
841
  if pick <= interior_weight:
613
- if _spawn("interior"):
842
+ if _spawn_interior():
614
843
  return True
615
- return _spawn("exterior")
616
- if _spawn("exterior"):
844
+ fall_result = _spawn_fall()
845
+ if fall_result == "scheduled":
846
+ return True
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":
617
857
  return True
618
- 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
@@ -83,6 +86,9 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
83
86
  outside_rects=[],
84
87
  walkable_cells=[],
85
88
  outer_wall_cells=set(),
89
+ wall_cells=set(),
90
+ fall_spawn_cells=set(),
91
+ bevel_corners={},
86
92
  ),
87
93
  fog={
88
94
  "hatch_patterns": {},
@@ -106,29 +112,26 @@ def carbonize_outdoor_zombies(game_data: GameData) -> None:
106
112
  if not group:
107
113
  return
108
114
  for zombie in list(group):
109
- alive = getattr(zombie, "alive", lambda: False)
110
- if not alive():
115
+ if not zombie.alive():
111
116
  continue
112
117
  center = zombie.rect.center
113
118
  if any(rect_obj.collidepoint(center) for rect_obj in outside_rects):
114
- carbonize = getattr(zombie, "carbonize", None)
115
- if carbonize:
116
- carbonize()
119
+ zombie.carbonize()
117
120
 
118
121
 
119
- def update_survival_timer(game_data: GameData, dt_ms: int) -> None:
120
- """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."""
121
124
  stage = game_data.stage
122
125
  state = game_data.state
123
- if not stage.survival_stage:
126
+ if not stage.endurance_stage:
124
127
  return
125
- if state.survival_goal_ms <= 0 or dt_ms <= 0:
128
+ if state.endurance_goal_ms <= 0 or dt_ms <= 0:
126
129
  return
127
- state.survival_elapsed_ms = min(
128
- state.survival_goal_ms,
129
- state.survival_elapsed_ms + dt_ms,
130
+ state.endurance_elapsed_ms = min(
131
+ state.endurance_goal_ms,
132
+ state.endurance_elapsed_ms + dt_ms,
130
133
  )
131
- 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:
132
135
  state.dawn_ready = True
133
136
  state.dawn_prompt_at = pygame.time.get_ticks()
134
137
  _set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=True)
@@ -16,15 +16,12 @@ from ..entities_constants import (
16
16
  SURVIVOR_RADIUS,
17
17
  ZOMBIE_RADIUS,
18
18
  )
19
- from .constants import (
20
- SURVIVOR_CONVERSION_LINE_KEYS,
21
- SURVIVOR_MESSAGE_DURATION_MS,
22
- SURVIVOR_SPEED_PENALTY_PER_PASSENGER,
23
- )
24
- from ..localization import translate as tr
19
+ from .constants import SURVIVOR_MESSAGE_DURATION_MS, SURVIVOR_SPEED_PENALTY_PER_PASSENGER
20
+ from ..localization import translate_dict, translate_list
25
21
  from ..models import GameData, ProgressState
26
22
  from ..rng import get_rng
27
- from ..entities import Survivor, spritecollideany_walls, WallIndex
23
+ from ..entities import Survivor, Zombie, spritecollideany_walls
24
+ from ..world_grid import WallIndex
28
25
  from .spawn import _create_zombie
29
26
  from .utils import find_nearby_offscreen_spawn_position, rect_visible_on_screen
30
27
 
@@ -51,6 +48,10 @@ def update_survivors(
51
48
  wall_group,
52
49
  wall_index=wall_index,
53
50
  cell_size=game_data.cell_size,
51
+ wall_cells=game_data.layout.wall_cells,
52
+ bevel_corners=game_data.layout.bevel_corners,
53
+ grid_cols=game_data.stage.grid_cols,
54
+ grid_rows=game_data.stage.grid_rows,
54
55
  level_width=game_data.level_width,
55
56
  level_height=game_data.level_height,
56
57
  )
@@ -150,11 +151,40 @@ def add_survivor_message(game_data: GameData, text: str) -> None:
150
151
  game_data.state.survivor_messages.append({"text": text, "expires_at": expires})
151
152
 
152
153
 
153
- def random_survivor_conversion_line() -> str:
154
- if not SURVIVOR_CONVERSION_LINE_KEYS:
154
+ def _normalize_legacy_conversion_lines(data: dict[str, Any]) -> list[str]:
155
+ numbered: list[tuple[int, str]] = []
156
+ others: list[tuple[str, str]] = []
157
+ for key, value in data.items():
158
+ if not value:
159
+ continue
160
+ text = str(value)
161
+ if isinstance(key, str) and key.startswith("line"):
162
+ suffix = key[4:]
163
+ if suffix.isdigit():
164
+ numbered.append((int(suffix), text))
165
+ continue
166
+ others.append((str(key), text))
167
+ numbered.sort(key=lambda item: item[0])
168
+ others.sort(key=lambda item: item[0])
169
+ return [text for _, text in numbered] + [text for _, text in others]
170
+
171
+
172
+ def _get_survivor_conversion_messages(stage_id: str) -> list[str]:
173
+ key = f"stages.{stage_id}.survivor_conversion_messages"
174
+ raw = translate_list(key)
175
+ if raw:
176
+ return [str(item) for item in raw if item]
177
+ legacy = translate_dict(f"stages.{stage_id}.conversion_lines")
178
+ if legacy:
179
+ return _normalize_legacy_conversion_lines(legacy)
180
+ return []
181
+
182
+
183
+ def random_survivor_conversion_line(stage_id: str) -> str:
184
+ lines = _get_survivor_conversion_messages(stage_id)
185
+ if not lines:
155
186
  return ""
156
- key = RNG.choice(SURVIVOR_CONVERSION_LINE_KEYS)
157
- return tr(key)
187
+ return RNG.choice(lines)
158
188
 
159
189
 
160
190
  def cleanup_survivor_messages(state: ProgressState) -> None:
@@ -224,7 +254,7 @@ def handle_survivor_zombie_collisions(
224
254
  min_x = survivor.rect.centerx - search_radius
225
255
  max_x = survivor.rect.centerx + search_radius
226
256
  start_idx = bisect_left(zombie_xs, min_x)
227
- collided = False
257
+ collided_zombie: Zombie | None = None
228
258
  for idx in range(start_idx, len(zombies)):
229
259
  zombie_x = zombie_xs[idx]
230
260
  if zombie_x > max_x:
@@ -237,10 +267,10 @@ def handle_survivor_zombie_collisions(
237
267
  continue
238
268
  dx = zombie_x - survivor.rect.centerx
239
269
  if dx * dx + dy * dy <= search_radius_sq:
240
- collided = True
270
+ collided_zombie = zombie
241
271
  break
242
272
 
243
- if not collided:
273
+ if collided_zombie is None:
244
274
  continue
245
275
  if not rect_visible_on_screen(camera, survivor.rect):
246
276
  spawn_pos = find_nearby_offscreen_spawn_position(
@@ -250,13 +280,15 @@ def handle_survivor_zombie_collisions(
250
280
  survivor.teleport(spawn_pos)
251
281
  continue
252
282
  survivor.kill()
253
- line = random_survivor_conversion_line()
283
+ line = random_survivor_conversion_line(game_data.stage.id)
254
284
  if line:
255
285
  add_survivor_message(game_data, line)
256
286
  new_zombie = _create_zombie(
257
287
  config,
258
288
  start_pos=survivor.rect.center,
259
289
  stage=game_data.stage,
290
+ tracker=collided_zombie.tracker,
291
+ wall_follower=collided_zombie.wall_follower,
260
292
  )
261
293
  zombie_group.add(new_zombie)
262
294
  game_data.groups.all_sprites.add(new_zombie, layer=1)
@@ -108,7 +108,9 @@ def find_nearby_offscreen_spawn_position(
108
108
  jitter_x = RNG.uniform(-cell.width * 0.35, cell.width * 0.35)
109
109
  jitter_y = RNG.uniform(-cell.height * 0.35, cell.height * 0.35)
110
110
  candidate = (int(cell.centerx + jitter_x), int(cell.centery + jitter_y))
111
- if player is not None and (min_distance_sq is not None or max_distance_sq is not None):
111
+ if player is not None and (
112
+ min_distance_sq is not None or max_distance_sq is not None
113
+ ):
112
114
  dx = candidate[0] - player.x
113
115
  dy = candidate[1] - player.y
114
116
  dist_sq = dx * dx + dy * dy
@@ -119,6 +121,22 @@ def find_nearby_offscreen_spawn_position(
119
121
  if view_rect is not None and view_rect.collidepoint(candidate):
120
122
  continue
121
123
  return candidate
124
+ if player is not None and (min_distance_sq is not None or max_distance_sq is not None):
125
+ for _ in range(20):
126
+ cell = RNG.choice(walkable_cells)
127
+ center = (cell.centerx, cell.centery)
128
+ if view_rect is not None and view_rect.collidepoint(center):
129
+ continue
130
+ dx = center[0] - player.x
131
+ dy = center[1] - player.y
132
+ dist_sq = dx * dx + dy * dy
133
+ if min_distance_sq is not None and dist_sq < min_distance_sq:
134
+ continue
135
+ if max_distance_sq is not None and dist_sq > max_distance_sq:
136
+ 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))
122
140
  fallback_cell = RNG.choice(walkable_cells)
123
141
  fallback_x = RNG.uniform(-fallback_cell.width * 0.35, fallback_cell.width * 0.35)
124
142
  fallback_y = RNG.uniform(-fallback_cell.height * 0.35, fallback_cell.height * 0.35)