zombie-escape 1.5.4__py3-none-any.whl → 1.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/entities.py +501 -537
  3. zombie_escape/entities_constants.py +102 -0
  4. zombie_escape/gameplay/__init__.py +75 -2
  5. zombie_escape/gameplay/ambient.py +50 -0
  6. zombie_escape/gameplay/constants.py +46 -0
  7. zombie_escape/gameplay/footprints.py +60 -0
  8. zombie_escape/gameplay/interactions.py +354 -0
  9. zombie_escape/gameplay/layout.py +190 -0
  10. zombie_escape/gameplay/movement.py +220 -0
  11. zombie_escape/gameplay/spawn.py +618 -0
  12. zombie_escape/gameplay/state.py +137 -0
  13. zombie_escape/gameplay/survivors.py +306 -0
  14. zombie_escape/gameplay/utils.py +147 -0
  15. zombie_escape/gameplay_constants.py +0 -148
  16. zombie_escape/level_blueprints.py +123 -10
  17. zombie_escape/level_constants.py +6 -13
  18. zombie_escape/locales/ui.en.json +10 -1
  19. zombie_escape/locales/ui.ja.json +10 -1
  20. zombie_escape/models.py +15 -9
  21. zombie_escape/render.py +42 -27
  22. zombie_escape/render_assets.py +533 -23
  23. zombie_escape/render_constants.py +57 -22
  24. zombie_escape/rng.py +9 -9
  25. zombie_escape/screens/__init__.py +59 -29
  26. zombie_escape/screens/game_over.py +3 -3
  27. zombie_escape/screens/gameplay.py +45 -27
  28. zombie_escape/screens/title.py +5 -2
  29. zombie_escape/stage_constants.py +34 -1
  30. zombie_escape/zombie_escape.py +30 -12
  31. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/METADATA +1 -1
  32. zombie_escape-1.7.1.dist-info/RECORD +45 -0
  33. zombie_escape/gameplay/logic.py +0 -1917
  34. zombie_escape-1.5.4.dist-info/RECORD +0 -35
  35. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/WHEEL +0 -0
  36. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/entry_points.txt +0 -0
  37. {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pygame
6
+
7
+ from ..colors import DAWN_AMBIENT_PALETTE_KEY, ambient_palette_key_for_flashlights
8
+ from ..entities_constants import SURVIVOR_MAX_SAFE_PASSENGERS
9
+ from ..models import GameData, Groups, LevelLayout, ProgressState, Stage
10
+ from ..entities import Camera
11
+ from .ambient import _set_ambient_palette
12
+
13
+
14
+ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
15
+ """Initialize and return the base game state objects."""
16
+ starts_with_fuel = not stage.requires_fuel
17
+ if stage.survival_stage:
18
+ starts_with_fuel = False
19
+ starts_with_flashlight = False
20
+ initial_flashlights = 1 if starts_with_flashlight else 0
21
+ initial_palette_key = ambient_palette_key_for_flashlights(initial_flashlights)
22
+ game_state = ProgressState(
23
+ game_over=False,
24
+ game_won=False,
25
+ game_over_message=None,
26
+ game_over_at=None,
27
+ scaled_overview=None,
28
+ overview_created=False,
29
+ footprints=[],
30
+ last_footprint_pos=None,
31
+ elapsed_play_ms=0,
32
+ has_fuel=starts_with_fuel,
33
+ flashlight_count=initial_flashlights,
34
+ ambient_palette_key=initial_palette_key,
35
+ hint_expires_at=0,
36
+ hint_target_type=None,
37
+ fuel_message_until=0,
38
+ buddy_rescued=0,
39
+ buddy_onboard=0,
40
+ survivors_onboard=0,
41
+ survivors_rescued=0,
42
+ survivor_messages=[],
43
+ survivor_capacity=SURVIVOR_MAX_SAFE_PASSENGERS,
44
+ seed=None,
45
+ survival_elapsed_ms=0,
46
+ survival_goal_ms=max(0, stage.survival_goal_ms),
47
+ dawn_ready=False,
48
+ dawn_prompt_at=None,
49
+ time_accel_active=False,
50
+ last_zombie_spawn_time=0,
51
+ dawn_carbonized=False,
52
+ debug_mode=False,
53
+ )
54
+
55
+ # Create sprite groups
56
+ all_sprites = pygame.sprite.LayeredUpdates()
57
+ wall_group = pygame.sprite.Group()
58
+ zombie_group = pygame.sprite.Group()
59
+ survivor_group = pygame.sprite.Group()
60
+
61
+ # Create camera
62
+ cell_size = stage.tile_size
63
+ level_width = stage.grid_cols * cell_size
64
+ level_height = stage.grid_rows * cell_size
65
+ camera = Camera(level_width, level_height)
66
+
67
+ # Define level layout (will be filled by blueprint generation)
68
+ outer_rect = 0, 0, level_width, level_height
69
+ inner_rect = outer_rect
70
+
71
+ return GameData(
72
+ state=game_state,
73
+ groups=Groups(
74
+ all_sprites=all_sprites,
75
+ wall_group=wall_group,
76
+ zombie_group=zombie_group,
77
+ survivor_group=survivor_group,
78
+ ),
79
+ camera=camera,
80
+ layout=LevelLayout(
81
+ outer_rect=outer_rect,
82
+ inner_rect=inner_rect,
83
+ outside_rects=[],
84
+ walkable_cells=[],
85
+ outer_wall_cells=set(),
86
+ ),
87
+ fog={
88
+ "hatch_patterns": {},
89
+ "overlays": {},
90
+ },
91
+ stage=stage,
92
+ cell_size=cell_size,
93
+ level_width=level_width,
94
+ level_height=level_height,
95
+ fuel=None,
96
+ flashlights=[],
97
+ )
98
+
99
+
100
+ def carbonize_outdoor_zombies(game_data: GameData) -> None:
101
+ """Petrify zombies that have already broken through to the exterior."""
102
+ outside_rects = game_data.layout.outside_rects or []
103
+ if not outside_rects:
104
+ return
105
+ group = game_data.groups.zombie_group
106
+ if not group:
107
+ return
108
+ for zombie in list(group):
109
+ alive = getattr(zombie, "alive", lambda: False)
110
+ if not alive():
111
+ continue
112
+ center = zombie.rect.center
113
+ if any(rect_obj.collidepoint(center) for rect_obj in outside_rects):
114
+ carbonize = getattr(zombie, "carbonize", None)
115
+ if carbonize:
116
+ carbonize()
117
+
118
+
119
+ def update_survival_timer(game_data: GameData, dt_ms: int) -> None:
120
+ """Advance the survival countdown and trigger dawn handoff."""
121
+ stage = game_data.stage
122
+ state = game_data.state
123
+ if not stage.survival_stage:
124
+ return
125
+ if state.survival_goal_ms <= 0 or dt_ms <= 0:
126
+ return
127
+ state.survival_elapsed_ms = min(
128
+ state.survival_goal_ms,
129
+ state.survival_elapsed_ms + dt_ms,
130
+ )
131
+ if not state.dawn_ready and state.survival_elapsed_ms >= state.survival_goal_ms:
132
+ state.dawn_ready = True
133
+ state.dawn_prompt_at = pygame.time.get_ticks()
134
+ _set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=True)
135
+ if state.dawn_ready:
136
+ carbonize_outdoor_zombies(game_data)
137
+ state.dawn_carbonized = True
@@ -0,0 +1,306 @@
1
+ from __future__ import annotations
2
+
3
+ from bisect import bisect_left
4
+ from typing import Any
5
+
6
+ import math
7
+
8
+ import pygame
9
+
10
+ from ..entities_constants import (
11
+ BUDDY_RADIUS,
12
+ CAR_SPEED,
13
+ PLAYER_RADIUS,
14
+ SURVIVOR_MAX_SAFE_PASSENGERS,
15
+ SURVIVOR_MIN_SPEED_FACTOR,
16
+ SURVIVOR_RADIUS,
17
+ ZOMBIE_RADIUS,
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
25
+ from ..models import GameData, ProgressState
26
+ from ..rng import get_rng
27
+ from ..entities import Survivor, spritecollideany_walls, WallIndex
28
+ from .spawn import _create_zombie
29
+ from .utils import find_nearby_offscreen_spawn_position, rect_visible_on_screen
30
+
31
+ RNG = get_rng()
32
+
33
+
34
+ def update_survivors(
35
+ game_data: GameData, wall_index: WallIndex | None = None
36
+ ) -> None:
37
+ if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
38
+ return
39
+ survivor_group = game_data.groups.survivor_group
40
+ wall_group = game_data.groups.wall_group
41
+ player = game_data.player
42
+ car = game_data.car
43
+ if not player:
44
+ return
45
+ target_rect = car.rect if player.in_car and car and car.alive() else player.rect
46
+ target_pos = target_rect.center
47
+ survivors = [s for s in survivor_group if s.alive()]
48
+ for survivor in survivors:
49
+ survivor.update_behavior(
50
+ target_pos,
51
+ wall_group,
52
+ wall_index=wall_index,
53
+ cell_size=game_data.cell_size,
54
+ level_width=game_data.level_width,
55
+ level_height=game_data.level_height,
56
+ )
57
+
58
+ # Gently prevent survivors from overlapping the player or each other
59
+ def _separate_from_point(
60
+ survivor: Survivor, point: tuple[float, float], min_dist: float
61
+ ) -> None:
62
+ dx = point[0] - survivor.x
63
+ dy = point[1] - survivor.y
64
+ dist = math.hypot(dx, dy)
65
+ if dist == 0:
66
+ angle = RNG.uniform(0, math.tau)
67
+ dx, dy = math.cos(angle), math.sin(angle)
68
+ dist = 1
69
+ if dist < min_dist:
70
+ push = min_dist - dist
71
+ survivor.x -= (dx / dist) * push
72
+ survivor.y -= (dy / dist) * push
73
+ survivor.rect.center = (int(survivor.x), int(survivor.y))
74
+
75
+ player_overlap = (SURVIVOR_RADIUS + PLAYER_RADIUS) * 1.05
76
+ survivor_overlap = (SURVIVOR_RADIUS * 2) * 1.05
77
+
78
+ player_point = (player.x, player.y)
79
+ for survivor in survivors:
80
+ _separate_from_point(survivor, player_point, player_overlap)
81
+
82
+ survivors_with_x = sorted(
83
+ ((survivor.x, survivor) for survivor in survivors), key=lambda item: item[0]
84
+ )
85
+ for i, (base_x, survivor) in enumerate(survivors_with_x):
86
+ for other_base_x, other in survivors_with_x[i + 1 :]:
87
+ if other_base_x - base_x > survivor_overlap:
88
+ break
89
+ dx = other.x - survivor.x
90
+ dy = other.y - survivor.y
91
+ dist = math.hypot(dx, dy)
92
+ if dist == 0:
93
+ angle = RNG.uniform(0, math.tau)
94
+ dx, dy = math.cos(angle), math.sin(angle)
95
+ dist = 1
96
+ if dist < survivor_overlap:
97
+ push = (survivor_overlap - dist) / 2
98
+ offset_x = (dx / dist) * push
99
+ offset_y = (dy / dist) * push
100
+ survivor.x -= offset_x
101
+ survivor.y -= offset_y
102
+ other.x += offset_x
103
+ other.y += offset_y
104
+ survivor.rect.center = (int(survivor.x), int(survivor.y))
105
+ other.rect.center = (int(other.x), int(other.y))
106
+
107
+
108
+ def calculate_car_speed_for_passengers(
109
+ passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
110
+ ) -> float:
111
+ cap = max(1, capacity)
112
+ load_ratio = max(0.0, passengers / cap)
113
+ penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * load_ratio
114
+ penalty = min(0.95, max(0.0, penalty))
115
+ adjusted = CAR_SPEED * (1 - penalty)
116
+ if passengers <= cap:
117
+ return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, adjusted)
118
+
119
+ overload = passengers - cap
120
+ overload_factor = 1 / math.sqrt(overload + 1)
121
+ overloaded_speed = CAR_SPEED * overload_factor
122
+ return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, overloaded_speed)
123
+
124
+
125
+ def apply_passenger_speed_penalty(game_data: GameData) -> None:
126
+ car = game_data.car
127
+ if not car:
128
+ return
129
+ if not game_data.stage.rescue_stage:
130
+ car.speed = CAR_SPEED
131
+ return
132
+ car.speed = calculate_car_speed_for_passengers(
133
+ game_data.state.survivors_onboard,
134
+ capacity=game_data.state.survivor_capacity,
135
+ )
136
+
137
+
138
+ def increase_survivor_capacity(game_data: GameData, increments: int = 1) -> None:
139
+ if increments <= 0:
140
+ return
141
+ if not game_data.stage.rescue_stage:
142
+ return
143
+ state = game_data.state
144
+ state.survivor_capacity += increments * SURVIVOR_MAX_SAFE_PASSENGERS
145
+ apply_passenger_speed_penalty(game_data)
146
+
147
+
148
+ def add_survivor_message(game_data: GameData, text: str) -> None:
149
+ expires = pygame.time.get_ticks() + SURVIVOR_MESSAGE_DURATION_MS
150
+ game_data.state.survivor_messages.append({"text": text, "expires_at": expires})
151
+
152
+
153
+ def random_survivor_conversion_line() -> str:
154
+ if not SURVIVOR_CONVERSION_LINE_KEYS:
155
+ return ""
156
+ key = RNG.choice(SURVIVOR_CONVERSION_LINE_KEYS)
157
+ return tr(key)
158
+
159
+
160
+ def cleanup_survivor_messages(state: ProgressState) -> None:
161
+ now = pygame.time.get_ticks()
162
+ state.survivor_messages = [
163
+ msg for msg in state.survivor_messages if msg.get("expires_at", 0) > now
164
+ ]
165
+
166
+
167
+ def drop_survivors_from_car(game_data: GameData, origin: tuple[int, int]) -> None:
168
+ """Respawn boarded survivors back into the world after a crash."""
169
+ count = game_data.state.survivors_onboard
170
+ if count <= 0:
171
+ return
172
+ wall_group = game_data.groups.wall_group
173
+ survivor_group = game_data.groups.survivor_group
174
+ all_sprites = game_data.groups.all_sprites
175
+
176
+ for survivor_idx in range(count):
177
+ placed = False
178
+ for attempt in range(6):
179
+ angle = RNG.uniform(0, math.tau)
180
+ dist = RNG.uniform(16, 40)
181
+ pos = (
182
+ origin[0] + math.cos(angle) * dist,
183
+ origin[1] + math.sin(angle) * dist,
184
+ )
185
+ s = Survivor(*pos)
186
+ if not spritecollideany_walls(s, wall_group):
187
+ survivor_group.add(s)
188
+ all_sprites.add(s, layer=1)
189
+ placed = True
190
+ break
191
+ if not placed:
192
+ s = Survivor(*origin)
193
+ survivor_group.add(s)
194
+ all_sprites.add(s, layer=1)
195
+
196
+ game_data.state.survivors_onboard = 0
197
+ apply_passenger_speed_penalty(game_data)
198
+
199
+
200
+ def handle_survivor_zombie_collisions(
201
+ game_data: GameData, config: dict[str, Any]
202
+ ) -> None:
203
+ if not game_data.stage.rescue_stage:
204
+ return
205
+ survivor_group = game_data.groups.survivor_group
206
+ if not survivor_group:
207
+ return
208
+ zombie_group = game_data.groups.zombie_group
209
+ zombies = [z for z in zombie_group if z.alive()]
210
+ if not zombies:
211
+ return
212
+ zombies.sort(key=lambda s: s.rect.centerx)
213
+ zombie_xs = [z.rect.centerx for z in zombies]
214
+ camera = game_data.camera
215
+ walkable_cells = game_data.layout.walkable_cells
216
+
217
+ for survivor in list(survivor_group):
218
+ if not survivor.alive():
219
+ continue
220
+ survivor_radius = survivor.radius
221
+ search_radius = survivor_radius + ZOMBIE_RADIUS
222
+ search_radius_sq = search_radius * search_radius
223
+
224
+ min_x = survivor.rect.centerx - search_radius
225
+ max_x = survivor.rect.centerx + search_radius
226
+ start_idx = bisect_left(zombie_xs, min_x)
227
+ collided = False
228
+ for idx in range(start_idx, len(zombies)):
229
+ zombie_x = zombie_xs[idx]
230
+ if zombie_x > max_x:
231
+ break
232
+ zombie = zombies[idx]
233
+ if not zombie.alive():
234
+ continue
235
+ dy = zombie.rect.centery - survivor.rect.centery
236
+ if abs(dy) > search_radius:
237
+ continue
238
+ dx = zombie_x - survivor.rect.centerx
239
+ if dx * dx + dy * dy <= search_radius_sq:
240
+ collided = True
241
+ break
242
+
243
+ if not collided:
244
+ continue
245
+ if not rect_visible_on_screen(camera, survivor.rect):
246
+ spawn_pos = find_nearby_offscreen_spawn_position(
247
+ walkable_cells,
248
+ camera=camera,
249
+ )
250
+ survivor.teleport(spawn_pos)
251
+ continue
252
+ survivor.kill()
253
+ line = random_survivor_conversion_line()
254
+ if line:
255
+ add_survivor_message(game_data, line)
256
+ new_zombie = _create_zombie(
257
+ config,
258
+ start_pos=survivor.rect.center,
259
+ stage=game_data.stage,
260
+ )
261
+ zombie_group.add(new_zombie)
262
+ game_data.groups.all_sprites.add(new_zombie, layer=1)
263
+ insert_idx = bisect_left(zombie_xs, new_zombie.rect.centerx)
264
+ zombie_xs.insert(insert_idx, new_zombie.rect.centerx)
265
+ zombies.insert(insert_idx, new_zombie)
266
+
267
+
268
+ def respawn_buddies_near_player(game_data: GameData) -> None:
269
+ """Bring back onboard buddies near the player after losing the car."""
270
+ if game_data.stage.buddy_required_count <= 0:
271
+ return
272
+ count = game_data.state.buddy_onboard
273
+ if count <= 0:
274
+ return
275
+
276
+ player = game_data.player
277
+ assert player is not None
278
+ wall_group = game_data.groups.wall_group
279
+ camera = game_data.camera
280
+ walkable_cells = game_data.layout.walkable_cells
281
+ offsets = [
282
+ (BUDDY_RADIUS * 3, 0),
283
+ (-BUDDY_RADIUS * 3, 0),
284
+ (0, BUDDY_RADIUS * 3),
285
+ (0, -BUDDY_RADIUS * 3),
286
+ (0, 0),
287
+ ]
288
+ for _ in range(count):
289
+ if walkable_cells:
290
+ spawn_pos = find_nearby_offscreen_spawn_position(
291
+ walkable_cells,
292
+ camera=camera,
293
+ )
294
+ else:
295
+ spawn_pos = (int(player.x), int(player.y))
296
+ for dx, dy in offsets:
297
+ candidate = Survivor(player.x + dx, player.y + dy, is_buddy=True)
298
+ if not spritecollideany_walls(candidate, wall_group):
299
+ spawn_pos = (candidate.x, candidate.y)
300
+ break
301
+
302
+ buddy = Survivor(*spawn_pos, is_buddy=True)
303
+ buddy.following = True
304
+ game_data.groups.all_sprites.add(buddy, layer=2)
305
+ game_data.groups.survivor_group.add(buddy)
306
+ game_data.state.buddy_onboard = 0
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import pygame
4
+
5
+ from ..entities import Camera, Player, random_position_outside_building
6
+ from ..rng import get_rng
7
+ from ..screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
8
+
9
+ LOGICAL_SCREEN_RECT = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
10
+ RNG = get_rng()
11
+
12
+ __all__ = [
13
+ "LOGICAL_SCREEN_RECT",
14
+ "rect_visible_on_screen",
15
+ "find_interior_spawn_positions",
16
+ "find_nearby_offscreen_spawn_position",
17
+ "find_exterior_spawn_position",
18
+ ]
19
+
20
+
21
+ def rect_visible_on_screen(camera: Camera | None, rect: pygame.Rect) -> bool:
22
+ if camera is None:
23
+ return False
24
+ return camera.apply_rect(rect).colliderect(LOGICAL_SCREEN_RECT)
25
+
26
+
27
+ def _scatter_positions_on_walkable(
28
+ walkable_cells: list[pygame.Rect],
29
+ spawn_rate: float,
30
+ *,
31
+ jitter_ratio: float = 0.35,
32
+ ) -> list[tuple[int, int]]:
33
+ positions: list[tuple[int, int]] = []
34
+ if not walkable_cells or spawn_rate <= 0:
35
+ return positions
36
+
37
+ clamped_rate = max(0.0, min(1.0, spawn_rate))
38
+ for cell in walkable_cells:
39
+ if RNG.random() >= clamped_rate:
40
+ continue
41
+ jitter_x = RNG.uniform(-cell.width * jitter_ratio, cell.width * jitter_ratio)
42
+ jitter_y = RNG.uniform(-cell.height * jitter_ratio, cell.height * jitter_ratio)
43
+ positions.append((int(cell.centerx + jitter_x), int(cell.centery + jitter_y)))
44
+ return positions
45
+
46
+
47
+ def find_interior_spawn_positions(
48
+ walkable_cells: list[pygame.Rect],
49
+ spawn_rate: float,
50
+ *,
51
+ player: Player | None = None,
52
+ min_player_dist: float | None = None,
53
+ ) -> list[tuple[int, int]]:
54
+ positions = _scatter_positions_on_walkable(
55
+ walkable_cells,
56
+ spawn_rate,
57
+ jitter_ratio=0.35,
58
+ )
59
+ if not positions and spawn_rate > 0:
60
+ positions = _scatter_positions_on_walkable(
61
+ walkable_cells,
62
+ spawn_rate * 1.5,
63
+ jitter_ratio=0.35,
64
+ )
65
+ if not positions:
66
+ return []
67
+ if player is None or min_player_dist is None or min_player_dist <= 0:
68
+ return positions
69
+ min_player_dist_sq = min_player_dist * min_player_dist
70
+ filtered: list[tuple[int, int]] = []
71
+ for pos in positions:
72
+ dx = pos[0] - player.x
73
+ dy = pos[1] - player.y
74
+ if dx * dx + dy * dy < min_player_dist_sq:
75
+ continue
76
+ filtered.append(pos)
77
+ return filtered
78
+
79
+
80
+ def find_nearby_offscreen_spawn_position(
81
+ walkable_cells: list[pygame.Rect],
82
+ *,
83
+ player: Player | None = None,
84
+ camera: Camera | None = None,
85
+ min_player_dist: float | None = None,
86
+ max_player_dist: float | None = None,
87
+ attempts: int = 18,
88
+ ) -> tuple[int, int]:
89
+ if not walkable_cells:
90
+ raise ValueError("walkable_cells must not be empty")
91
+ view_rect = None
92
+ if camera is not None:
93
+ view_rect = pygame.Rect(
94
+ -camera.camera.x,
95
+ -camera.camera.y,
96
+ SCREEN_WIDTH,
97
+ SCREEN_HEIGHT,
98
+ )
99
+ view_rect.inflate_ip(SCREEN_WIDTH, SCREEN_HEIGHT)
100
+ min_distance_sq = (
101
+ None if min_player_dist is None else min_player_dist * min_player_dist
102
+ )
103
+ max_distance_sq = (
104
+ None if max_player_dist is None else max_player_dist * max_player_dist
105
+ )
106
+ for _ in range(max(1, attempts)):
107
+ cell = RNG.choice(walkable_cells)
108
+ jitter_x = RNG.uniform(-cell.width * 0.35, cell.width * 0.35)
109
+ jitter_y = RNG.uniform(-cell.height * 0.35, cell.height * 0.35)
110
+ candidate = (int(cell.centerx + jitter_x), int(cell.centery + jitter_y))
111
+ if player is not None and (min_distance_sq is not None or max_distance_sq is not None):
112
+ dx = candidate[0] - player.x
113
+ dy = candidate[1] - player.y
114
+ dist_sq = dx * dx + dy * dy
115
+ if min_distance_sq is not None and dist_sq < min_distance_sq:
116
+ continue
117
+ if max_distance_sq is not None and dist_sq > max_distance_sq:
118
+ continue
119
+ if view_rect is not None and view_rect.collidepoint(candidate):
120
+ continue
121
+ return candidate
122
+ fallback_cell = RNG.choice(walkable_cells)
123
+ fallback_x = RNG.uniform(-fallback_cell.width * 0.35, fallback_cell.width * 0.35)
124
+ fallback_y = RNG.uniform(-fallback_cell.height * 0.35, fallback_cell.height * 0.35)
125
+ return (
126
+ int(fallback_cell.centerx + fallback_x),
127
+ int(fallback_cell.centery + fallback_y),
128
+ )
129
+
130
+
131
+ def find_exterior_spawn_position(
132
+ level_width: int,
133
+ level_height: int,
134
+ *,
135
+ hint_pos: tuple[float, float] | None = None,
136
+ attempts: int = 5,
137
+ ) -> tuple[int, int]:
138
+ if hint_pos is None:
139
+ return random_position_outside_building(level_width, level_height)
140
+ points = [
141
+ random_position_outside_building(level_width, level_height)
142
+ for _ in range(max(1, attempts))
143
+ ]
144
+ return min(
145
+ points,
146
+ key=lambda pos: (pos[0] - hint_pos[0]) ** 2 + (pos[1] - hint_pos[1]) ** 2,
147
+ )