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.
- zombie_escape/__about__.py +1 -1
- zombie_escape/entities.py +501 -537
- zombie_escape/entities_constants.py +102 -0
- zombie_escape/gameplay/__init__.py +75 -2
- zombie_escape/gameplay/ambient.py +50 -0
- zombie_escape/gameplay/constants.py +46 -0
- zombie_escape/gameplay/footprints.py +60 -0
- zombie_escape/gameplay/interactions.py +354 -0
- zombie_escape/gameplay/layout.py +190 -0
- zombie_escape/gameplay/movement.py +220 -0
- zombie_escape/gameplay/spawn.py +618 -0
- zombie_escape/gameplay/state.py +137 -0
- zombie_escape/gameplay/survivors.py +306 -0
- zombie_escape/gameplay/utils.py +147 -0
- zombie_escape/gameplay_constants.py +0 -148
- zombie_escape/level_blueprints.py +123 -10
- zombie_escape/level_constants.py +6 -13
- zombie_escape/locales/ui.en.json +10 -1
- zombie_escape/locales/ui.ja.json +10 -1
- zombie_escape/models.py +15 -9
- zombie_escape/render.py +42 -27
- zombie_escape/render_assets.py +533 -23
- zombie_escape/render_constants.py +57 -22
- zombie_escape/rng.py +9 -9
- zombie_escape/screens/__init__.py +59 -29
- zombie_escape/screens/game_over.py +3 -3
- zombie_escape/screens/gameplay.py +45 -27
- zombie_escape/screens/title.py +5 -2
- zombie_escape/stage_constants.py +34 -1
- zombie_escape/zombie_escape.py +30 -12
- {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/METADATA +1 -1
- zombie_escape-1.7.1.dist-info/RECORD +45 -0
- zombie_escape/gameplay/logic.py +0 -1917
- zombie_escape-1.5.4.dist-info/RECORD +0 -35
- {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/WHEEL +0 -0
- {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.5.4.dist-info → zombie_escape-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Sequence
|
|
4
|
+
|
|
5
|
+
import pygame
|
|
6
|
+
|
|
7
|
+
from ..entities import (
|
|
8
|
+
Car,
|
|
9
|
+
Flashlight,
|
|
10
|
+
FuelCan,
|
|
11
|
+
Player,
|
|
12
|
+
Survivor,
|
|
13
|
+
Zombie,
|
|
14
|
+
random_position_outside_building,
|
|
15
|
+
spritecollideany_walls,
|
|
16
|
+
)
|
|
17
|
+
from ..entities_constants import (
|
|
18
|
+
FAST_ZOMBIE_BASE_SPEED,
|
|
19
|
+
PLAYER_SPEED,
|
|
20
|
+
ZOMBIE_AGING_DURATION_FRAMES,
|
|
21
|
+
ZOMBIE_SPEED,
|
|
22
|
+
)
|
|
23
|
+
from ..gameplay_constants import DEFAULT_FLASHLIGHT_SPAWN_COUNT
|
|
24
|
+
from ..level_constants import DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TILE_SIZE
|
|
25
|
+
from ..models import GameData, Stage
|
|
26
|
+
from ..rng import get_rng
|
|
27
|
+
from .constants import (
|
|
28
|
+
MAX_ZOMBIES,
|
|
29
|
+
ZOMBIE_SPAWN_PLAYER_BUFFER,
|
|
30
|
+
ZOMBIE_TRACKER_AGING_DURATION_FRAMES,
|
|
31
|
+
)
|
|
32
|
+
from .utils import (
|
|
33
|
+
find_exterior_spawn_position,
|
|
34
|
+
find_interior_spawn_positions,
|
|
35
|
+
find_nearby_offscreen_spawn_position,
|
|
36
|
+
rect_visible_on_screen,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
RNG = get_rng()
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"place_new_car",
|
|
43
|
+
"place_fuel_can",
|
|
44
|
+
"place_flashlights",
|
|
45
|
+
"place_buddies",
|
|
46
|
+
"spawn_survivors",
|
|
47
|
+
"setup_player_and_cars",
|
|
48
|
+
"spawn_initial_zombies",
|
|
49
|
+
"spawn_waiting_car",
|
|
50
|
+
"maintain_waiting_car_supply",
|
|
51
|
+
"nearest_waiting_car",
|
|
52
|
+
"spawn_exterior_zombie",
|
|
53
|
+
"spawn_weighted_zombie",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _car_appearance_for_stage(stage: Stage | None) -> str:
|
|
58
|
+
return "disabled" if stage and stage.survival_stage else "default"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _create_zombie(
|
|
62
|
+
config: dict[str, Any],
|
|
63
|
+
*,
|
|
64
|
+
start_pos: tuple[int, int] | None = None,
|
|
65
|
+
hint_pos: tuple[float, float] | None = None,
|
|
66
|
+
stage: Stage | None = None,
|
|
67
|
+
tracker: bool | None = None,
|
|
68
|
+
wall_follower: bool | None = None,
|
|
69
|
+
) -> Zombie:
|
|
70
|
+
"""Factory to create zombies with optional fast variants."""
|
|
71
|
+
fast_conf = config.get("fast_zombies", {})
|
|
72
|
+
fast_enabled = fast_conf.get("enabled", True)
|
|
73
|
+
if fast_enabled:
|
|
74
|
+
base_speed = RNG.uniform(ZOMBIE_SPEED, FAST_ZOMBIE_BASE_SPEED)
|
|
75
|
+
else:
|
|
76
|
+
base_speed = ZOMBIE_SPEED
|
|
77
|
+
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
|
+
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
|
+
aging_duration_frames = max(
|
|
96
|
+
1.0,
|
|
97
|
+
float(stage.zombie_aging_duration_frames),
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
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
|
|
116
|
+
if tracker:
|
|
117
|
+
wall_follower = False
|
|
118
|
+
if tracker:
|
|
119
|
+
ratio = (
|
|
120
|
+
ZOMBIE_TRACKER_AGING_DURATION_FRAMES / ZOMBIE_AGING_DURATION_FRAMES
|
|
121
|
+
if ZOMBIE_AGING_DURATION_FRAMES > 0
|
|
122
|
+
else 1.0
|
|
123
|
+
)
|
|
124
|
+
aging_duration_frames = max(1.0, aging_duration_frames * ratio)
|
|
125
|
+
if start_pos is None:
|
|
126
|
+
tile_size = stage.tile_size if stage else DEFAULT_TILE_SIZE
|
|
127
|
+
if stage is None:
|
|
128
|
+
grid_cols = DEFAULT_GRID_COLS
|
|
129
|
+
grid_rows = DEFAULT_GRID_ROWS
|
|
130
|
+
else:
|
|
131
|
+
grid_cols = stage.grid_cols
|
|
132
|
+
grid_rows = stage.grid_rows
|
|
133
|
+
level_width = grid_cols * tile_size
|
|
134
|
+
level_height = grid_rows * tile_size
|
|
135
|
+
if hint_pos is not None:
|
|
136
|
+
points = [
|
|
137
|
+
random_position_outside_building(level_width, level_height)
|
|
138
|
+
for _ in range(5)
|
|
139
|
+
]
|
|
140
|
+
points.sort(
|
|
141
|
+
key=lambda p: (p[0] - hint_pos[0]) ** 2 + (p[1] - hint_pos[1]) ** 2
|
|
142
|
+
)
|
|
143
|
+
start_pos = points[0]
|
|
144
|
+
else:
|
|
145
|
+
start_pos = random_position_outside_building(level_width, level_height)
|
|
146
|
+
return Zombie(
|
|
147
|
+
x=float(start_pos[0]),
|
|
148
|
+
y=float(start_pos[1]),
|
|
149
|
+
speed=base_speed,
|
|
150
|
+
tracker=tracker,
|
|
151
|
+
wall_follower=wall_follower,
|
|
152
|
+
aging_duration_frames=aging_duration_frames,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def place_fuel_can(
|
|
157
|
+
walkable_cells: list[pygame.Rect],
|
|
158
|
+
player: Player,
|
|
159
|
+
*,
|
|
160
|
+
cars: Sequence[Car] | None = None,
|
|
161
|
+
count: int = 1,
|
|
162
|
+
) -> FuelCan | None:
|
|
163
|
+
"""Pick a spawn spot for the fuel can away from the player (and car if given)."""
|
|
164
|
+
if count <= 0 or not walkable_cells:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
min_player_dist = 250
|
|
168
|
+
min_car_dist = 200
|
|
169
|
+
min_player_dist_sq = min_player_dist * min_player_dist
|
|
170
|
+
min_car_dist_sq = min_car_dist * min_car_dist
|
|
171
|
+
|
|
172
|
+
for _ in range(200):
|
|
173
|
+
cell = RNG.choice(walkable_cells)
|
|
174
|
+
dx = cell.centerx - player.x
|
|
175
|
+
dy = cell.centery - player.y
|
|
176
|
+
if dx * dx + dy * dy < min_player_dist_sq:
|
|
177
|
+
continue
|
|
178
|
+
if cars:
|
|
179
|
+
too_close = False
|
|
180
|
+
for parked_car in cars:
|
|
181
|
+
dx = cell.centerx - parked_car.rect.centerx
|
|
182
|
+
dy = cell.centery - parked_car.rect.centery
|
|
183
|
+
if dx * dx + dy * dy < min_car_dist_sq:
|
|
184
|
+
too_close = True
|
|
185
|
+
break
|
|
186
|
+
if too_close:
|
|
187
|
+
continue
|
|
188
|
+
return FuelCan(cell.centerx, cell.centery)
|
|
189
|
+
|
|
190
|
+
cell = RNG.choice(walkable_cells)
|
|
191
|
+
return FuelCan(cell.centerx, cell.centery)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _place_flashlight(
|
|
195
|
+
walkable_cells: list[pygame.Rect],
|
|
196
|
+
player: Player,
|
|
197
|
+
*,
|
|
198
|
+
cars: Sequence[Car] | None = None,
|
|
199
|
+
) -> Flashlight | None:
|
|
200
|
+
"""Pick a spawn spot for the flashlight away from the player (and car if given)."""
|
|
201
|
+
if not walkable_cells:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
min_player_dist = 260
|
|
205
|
+
min_car_dist = 200
|
|
206
|
+
min_player_dist_sq = min_player_dist * min_player_dist
|
|
207
|
+
min_car_dist_sq = min_car_dist * min_car_dist
|
|
208
|
+
|
|
209
|
+
for _ in range(200):
|
|
210
|
+
cell = RNG.choice(walkable_cells)
|
|
211
|
+
dx = cell.centerx - player.x
|
|
212
|
+
dy = cell.centery - player.y
|
|
213
|
+
if dx * dx + dy * dy < min_player_dist_sq:
|
|
214
|
+
continue
|
|
215
|
+
if cars:
|
|
216
|
+
if any(
|
|
217
|
+
(cell.centerx - parked.rect.centerx) ** 2
|
|
218
|
+
+ (cell.centery - parked.rect.centery) ** 2
|
|
219
|
+
< min_car_dist_sq
|
|
220
|
+
for parked in cars
|
|
221
|
+
):
|
|
222
|
+
continue
|
|
223
|
+
return Flashlight(cell.centerx, cell.centery)
|
|
224
|
+
|
|
225
|
+
cell = RNG.choice(walkable_cells)
|
|
226
|
+
return Flashlight(cell.centerx, cell.centery)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def place_flashlights(
|
|
230
|
+
walkable_cells: list[pygame.Rect],
|
|
231
|
+
player: Player,
|
|
232
|
+
*,
|
|
233
|
+
cars: Sequence[Car] | None = None,
|
|
234
|
+
count: int = DEFAULT_FLASHLIGHT_SPAWN_COUNT,
|
|
235
|
+
) -> list[Flashlight]:
|
|
236
|
+
"""Spawn multiple flashlights using the single-place helper to spread them out."""
|
|
237
|
+
placed: list[Flashlight] = []
|
|
238
|
+
attempts = 0
|
|
239
|
+
max_attempts = max(200, count * 80)
|
|
240
|
+
while len(placed) < count and attempts < max_attempts:
|
|
241
|
+
attempts += 1
|
|
242
|
+
fl = _place_flashlight(walkable_cells, player, cars=cars)
|
|
243
|
+
if not fl:
|
|
244
|
+
break
|
|
245
|
+
# Avoid clustering too tightly
|
|
246
|
+
if any(
|
|
247
|
+
(other.rect.centerx - fl.rect.centerx) ** 2
|
|
248
|
+
+ (other.rect.centery - fl.rect.centery) ** 2
|
|
249
|
+
< 120 * 120
|
|
250
|
+
for other in placed
|
|
251
|
+
):
|
|
252
|
+
continue
|
|
253
|
+
placed.append(fl)
|
|
254
|
+
return placed
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def place_buddies(
|
|
258
|
+
walkable_cells: list[pygame.Rect],
|
|
259
|
+
player: Player,
|
|
260
|
+
*,
|
|
261
|
+
cars: Sequence[Car] | None = None,
|
|
262
|
+
count: int = 1,
|
|
263
|
+
) -> list[Survivor]:
|
|
264
|
+
placed: list[Survivor] = []
|
|
265
|
+
if count <= 0 or not walkable_cells:
|
|
266
|
+
return placed
|
|
267
|
+
min_player_dist = 240
|
|
268
|
+
positions = find_interior_spawn_positions(
|
|
269
|
+
walkable_cells,
|
|
270
|
+
1.0,
|
|
271
|
+
player=player,
|
|
272
|
+
min_player_dist=min_player_dist,
|
|
273
|
+
)
|
|
274
|
+
RNG.shuffle(positions)
|
|
275
|
+
for pos in positions[:count]:
|
|
276
|
+
placed.append(Survivor(pos[0], pos[1], is_buddy=True))
|
|
277
|
+
remaining = count - len(placed)
|
|
278
|
+
for _ in range(max(0, remaining)):
|
|
279
|
+
spawn_pos = find_nearby_offscreen_spawn_position(walkable_cells)
|
|
280
|
+
placed.append(Survivor(spawn_pos[0], spawn_pos[1], is_buddy=True))
|
|
281
|
+
return placed
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def place_new_car(
|
|
285
|
+
wall_group: pygame.sprite.Group,
|
|
286
|
+
player: Player,
|
|
287
|
+
walkable_cells: list[pygame.Rect],
|
|
288
|
+
*,
|
|
289
|
+
existing_cars: Sequence[Car] | None = None,
|
|
290
|
+
appearance: str = "default",
|
|
291
|
+
) -> Car | None:
|
|
292
|
+
if not walkable_cells:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
max_attempts = 150
|
|
296
|
+
for _ in range(max_attempts):
|
|
297
|
+
cell = RNG.choice(walkable_cells)
|
|
298
|
+
c_x, c_y = cell.center
|
|
299
|
+
temp_car = Car(c_x, c_y, appearance=appearance)
|
|
300
|
+
temp_rect = temp_car.rect.inflate(30, 30)
|
|
301
|
+
nearby_walls = pygame.sprite.Group()
|
|
302
|
+
nearby_walls.add(
|
|
303
|
+
[
|
|
304
|
+
w
|
|
305
|
+
for w in wall_group
|
|
306
|
+
if abs(w.rect.centerx - c_x) < 150 and abs(w.rect.centery - c_y) < 150
|
|
307
|
+
]
|
|
308
|
+
)
|
|
309
|
+
collides_wall = spritecollideany_walls(temp_car, nearby_walls)
|
|
310
|
+
collides_player = temp_rect.colliderect(player.rect.inflate(50, 50))
|
|
311
|
+
car_overlap = False
|
|
312
|
+
if existing_cars:
|
|
313
|
+
car_overlap = any(
|
|
314
|
+
temp_car.rect.colliderect(other.rect)
|
|
315
|
+
for other in existing_cars
|
|
316
|
+
if other and other.alive()
|
|
317
|
+
)
|
|
318
|
+
if not collides_wall and not collides_player and not car_overlap:
|
|
319
|
+
return temp_car
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def spawn_survivors(
|
|
324
|
+
game_data: GameData, layout_data: Mapping[str, list[pygame.Rect]]
|
|
325
|
+
) -> list[Survivor]:
|
|
326
|
+
"""Populate rescue-stage survivors and buddy-stage buddies."""
|
|
327
|
+
survivors: list[Survivor] = []
|
|
328
|
+
if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
|
|
329
|
+
return survivors
|
|
330
|
+
|
|
331
|
+
walkable = layout_data.get("walkable_cells", [])
|
|
332
|
+
wall_group = game_data.groups.wall_group
|
|
333
|
+
survivor_group = game_data.groups.survivor_group
|
|
334
|
+
all_sprites = game_data.groups.all_sprites
|
|
335
|
+
|
|
336
|
+
if game_data.stage.rescue_stage:
|
|
337
|
+
positions = find_interior_spawn_positions(
|
|
338
|
+
walkable,
|
|
339
|
+
game_data.stage.survivor_spawn_rate,
|
|
340
|
+
)
|
|
341
|
+
for pos in positions:
|
|
342
|
+
survivor = Survivor(*pos)
|
|
343
|
+
if spritecollideany_walls(survivor, wall_group):
|
|
344
|
+
continue
|
|
345
|
+
survivor_group.add(survivor)
|
|
346
|
+
all_sprites.add(survivor, layer=1)
|
|
347
|
+
survivors.append(survivor)
|
|
348
|
+
|
|
349
|
+
if game_data.stage.buddy_required_count > 0:
|
|
350
|
+
buddy_count = max(0, game_data.stage.buddy_required_count)
|
|
351
|
+
buddies: list[Survivor] = []
|
|
352
|
+
if game_data.player:
|
|
353
|
+
buddies = place_buddies(
|
|
354
|
+
walkable,
|
|
355
|
+
game_data.player,
|
|
356
|
+
cars=game_data.waiting_cars,
|
|
357
|
+
count=buddy_count,
|
|
358
|
+
)
|
|
359
|
+
for buddy in buddies:
|
|
360
|
+
if spritecollideany_walls(buddy, wall_group):
|
|
361
|
+
continue
|
|
362
|
+
survivor_group.add(buddy)
|
|
363
|
+
all_sprites.add(buddy, layer=2)
|
|
364
|
+
survivors.append(buddy)
|
|
365
|
+
|
|
366
|
+
return survivors
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def setup_player_and_cars(
|
|
370
|
+
game_data: GameData,
|
|
371
|
+
layout_data: Mapping[str, list[pygame.Rect]],
|
|
372
|
+
*,
|
|
373
|
+
car_count: int = 1,
|
|
374
|
+
) -> tuple[Player, list[Car]]:
|
|
375
|
+
"""Create the player plus one or more parked cars using blueprint candidates."""
|
|
376
|
+
all_sprites = game_data.groups.all_sprites
|
|
377
|
+
walkable_cells: list[pygame.Rect] = layout_data["walkable_cells"]
|
|
378
|
+
|
|
379
|
+
def _pick_center(cells: list[pygame.Rect]) -> tuple[int, int]:
|
|
380
|
+
return (
|
|
381
|
+
RNG.choice(cells).center
|
|
382
|
+
if cells
|
|
383
|
+
else (game_data.level_width // 2, game_data.level_height // 2)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
player_pos = _pick_center(layout_data["player_cells"] or walkable_cells)
|
|
387
|
+
player = Player(*player_pos)
|
|
388
|
+
|
|
389
|
+
car_candidates = list(layout_data["car_cells"] or walkable_cells)
|
|
390
|
+
waiting_cars: list[Car] = []
|
|
391
|
+
car_appearance = _car_appearance_for_stage(game_data.stage)
|
|
392
|
+
|
|
393
|
+
def _pick_car_position() -> tuple[int, int]:
|
|
394
|
+
"""Favor distant cells for the first car, otherwise fall back to random picks."""
|
|
395
|
+
if not car_candidates:
|
|
396
|
+
return (player_pos[0] + 200, player_pos[1])
|
|
397
|
+
RNG.shuffle(car_candidates)
|
|
398
|
+
for candidate in car_candidates:
|
|
399
|
+
if (candidate.centerx - player_pos[0]) ** 2 + (
|
|
400
|
+
candidate.centery - player_pos[1]
|
|
401
|
+
) ** 2 >= 400 * 400:
|
|
402
|
+
car_candidates.remove(candidate)
|
|
403
|
+
return candidate.center
|
|
404
|
+
choice = car_candidates.pop()
|
|
405
|
+
return choice.center
|
|
406
|
+
|
|
407
|
+
for _ in range(max(1, car_count)):
|
|
408
|
+
car_pos = _pick_car_position()
|
|
409
|
+
car = Car(*car_pos, appearance=car_appearance)
|
|
410
|
+
waiting_cars.append(car)
|
|
411
|
+
all_sprites.add(car, layer=1)
|
|
412
|
+
if not car_candidates:
|
|
413
|
+
break
|
|
414
|
+
|
|
415
|
+
all_sprites.add(player, layer=2)
|
|
416
|
+
return player, waiting_cars
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def spawn_initial_zombies(
|
|
420
|
+
game_data: GameData,
|
|
421
|
+
player: Player,
|
|
422
|
+
layout_data: Mapping[str, list[pygame.Rect]],
|
|
423
|
+
config: dict[str, Any],
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Spawn initial zombies using blueprint candidate cells."""
|
|
426
|
+
wall_group = game_data.groups.wall_group
|
|
427
|
+
zombie_group = game_data.groups.zombie_group
|
|
428
|
+
all_sprites = game_data.groups.all_sprites
|
|
429
|
+
|
|
430
|
+
spawn_cells = layout_data["walkable_cells"]
|
|
431
|
+
if not spawn_cells:
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
spawn_rate = max(0.0, game_data.stage.initial_interior_spawn_rate)
|
|
435
|
+
positions = find_interior_spawn_positions(
|
|
436
|
+
spawn_cells,
|
|
437
|
+
spawn_rate,
|
|
438
|
+
player=player,
|
|
439
|
+
min_player_dist=ZOMBIE_SPAWN_PLAYER_BUFFER,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
for pos in positions:
|
|
443
|
+
tentative = _create_zombie(
|
|
444
|
+
config,
|
|
445
|
+
start_pos=pos,
|
|
446
|
+
stage=game_data.stage,
|
|
447
|
+
)
|
|
448
|
+
if spritecollideany_walls(tentative, wall_group):
|
|
449
|
+
continue
|
|
450
|
+
zombie_group.add(tentative)
|
|
451
|
+
all_sprites.add(tentative, layer=1)
|
|
452
|
+
|
|
453
|
+
interval = max(1, game_data.stage.spawn_interval_ms)
|
|
454
|
+
game_data.state.last_zombie_spawn_time = pygame.time.get_ticks() - interval
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def spawn_waiting_car(game_data: GameData) -> Car | None:
|
|
458
|
+
"""Attempt to place an additional parked car on the map."""
|
|
459
|
+
player = game_data.player
|
|
460
|
+
if not player:
|
|
461
|
+
return None
|
|
462
|
+
walkable_cells = game_data.layout.walkable_cells
|
|
463
|
+
if not walkable_cells:
|
|
464
|
+
return None
|
|
465
|
+
wall_group = game_data.groups.wall_group
|
|
466
|
+
all_sprites = game_data.groups.all_sprites
|
|
467
|
+
active_car = game_data.car if game_data.car and game_data.car.alive() else None
|
|
468
|
+
waiting = _alive_waiting_cars(game_data)
|
|
469
|
+
obstacles: list[Car] = list(waiting)
|
|
470
|
+
if active_car:
|
|
471
|
+
obstacles.append(active_car)
|
|
472
|
+
camera = game_data.camera
|
|
473
|
+
appearance = _car_appearance_for_stage(game_data.stage)
|
|
474
|
+
offscreen_attempts = 6
|
|
475
|
+
while offscreen_attempts > 0:
|
|
476
|
+
new_car = place_new_car(
|
|
477
|
+
wall_group,
|
|
478
|
+
player,
|
|
479
|
+
walkable_cells,
|
|
480
|
+
existing_cars=obstacles,
|
|
481
|
+
appearance=appearance,
|
|
482
|
+
)
|
|
483
|
+
if not new_car:
|
|
484
|
+
return None
|
|
485
|
+
if rect_visible_on_screen(camera, new_car.rect):
|
|
486
|
+
offscreen_attempts -= 1
|
|
487
|
+
continue
|
|
488
|
+
game_data.waiting_cars.append(new_car)
|
|
489
|
+
all_sprites.add(new_car, layer=1)
|
|
490
|
+
return new_car
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def maintain_waiting_car_supply(
|
|
495
|
+
game_data: GameData, *, minimum: int | None = None
|
|
496
|
+
) -> None:
|
|
497
|
+
"""Ensure a baseline count of parked cars exists."""
|
|
498
|
+
target = 1 if minimum is None else max(0, minimum)
|
|
499
|
+
current = len(_alive_waiting_cars(game_data))
|
|
500
|
+
while current < target:
|
|
501
|
+
new_car = spawn_waiting_car(game_data)
|
|
502
|
+
if not new_car:
|
|
503
|
+
break
|
|
504
|
+
current += 1
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _alive_waiting_cars(game_data: GameData) -> list[Car]:
|
|
508
|
+
"""Return the list of parked cars that still exist, pruning any destroyed sprites."""
|
|
509
|
+
cars = [car for car in game_data.waiting_cars if car.alive()]
|
|
510
|
+
game_data.waiting_cars = cars
|
|
511
|
+
_log_waiting_car_count(game_data)
|
|
512
|
+
return cars
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _log_waiting_car_count(game_data: GameData, *, force: bool = False) -> None:
|
|
516
|
+
"""Print the number of waiting cars when it changes."""
|
|
517
|
+
current = len(game_data.waiting_cars)
|
|
518
|
+
if not force and current == game_data.last_logged_waiting_cars:
|
|
519
|
+
return
|
|
520
|
+
game_data.last_logged_waiting_cars = current
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def nearest_waiting_car(game_data: GameData, origin: tuple[float, float]) -> Car | None:
|
|
524
|
+
"""Find the closest waiting car to an origin point."""
|
|
525
|
+
cars = _alive_waiting_cars(game_data)
|
|
526
|
+
if not cars:
|
|
527
|
+
return None
|
|
528
|
+
return min(
|
|
529
|
+
cars,
|
|
530
|
+
key=lambda car: (car.rect.centerx - origin[0]) ** 2
|
|
531
|
+
+ (car.rect.centery - origin[1]) ** 2,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _spawn_nearby_zombie(
|
|
536
|
+
game_data: GameData,
|
|
537
|
+
config: dict[str, Any],
|
|
538
|
+
) -> Zombie | None:
|
|
539
|
+
"""Spawn a zombie just outside of the current camera frustum."""
|
|
540
|
+
player = game_data.player
|
|
541
|
+
if not player:
|
|
542
|
+
return None
|
|
543
|
+
zombie_group = game_data.groups.zombie_group
|
|
544
|
+
if len(zombie_group) >= MAX_ZOMBIES:
|
|
545
|
+
return None
|
|
546
|
+
camera = game_data.camera
|
|
547
|
+
wall_group = game_data.groups.wall_group
|
|
548
|
+
all_sprites = game_data.groups.all_sprites
|
|
549
|
+
spawn_pos = find_nearby_offscreen_spawn_position(
|
|
550
|
+
game_data.layout.walkable_cells,
|
|
551
|
+
player=player,
|
|
552
|
+
camera=camera,
|
|
553
|
+
attempts=50,
|
|
554
|
+
)
|
|
555
|
+
new_zombie = _create_zombie(
|
|
556
|
+
config,
|
|
557
|
+
start_pos=spawn_pos,
|
|
558
|
+
stage=game_data.stage,
|
|
559
|
+
)
|
|
560
|
+
if spritecollideany_walls(new_zombie, wall_group):
|
|
561
|
+
return None
|
|
562
|
+
zombie_group.add(new_zombie)
|
|
563
|
+
all_sprites.add(new_zombie, layer=1)
|
|
564
|
+
return new_zombie
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def spawn_exterior_zombie(
|
|
568
|
+
game_data: GameData,
|
|
569
|
+
config: dict[str, Any],
|
|
570
|
+
) -> Zombie | None:
|
|
571
|
+
"""Spawn a zombie using the standard exterior hint logic."""
|
|
572
|
+
player = game_data.player
|
|
573
|
+
if not player:
|
|
574
|
+
return None
|
|
575
|
+
zombie_group = game_data.groups.zombie_group
|
|
576
|
+
all_sprites = game_data.groups.all_sprites
|
|
577
|
+
spawn_pos = find_exterior_spawn_position(
|
|
578
|
+
game_data.level_width,
|
|
579
|
+
game_data.level_height,
|
|
580
|
+
hint_pos=(player.x, player.y),
|
|
581
|
+
)
|
|
582
|
+
new_zombie = _create_zombie(
|
|
583
|
+
config,
|
|
584
|
+
start_pos=spawn_pos,
|
|
585
|
+
stage=game_data.stage,
|
|
586
|
+
)
|
|
587
|
+
zombie_group.add(new_zombie)
|
|
588
|
+
all_sprites.add(new_zombie, layer=1)
|
|
589
|
+
return new_zombie
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def spawn_weighted_zombie(
|
|
593
|
+
game_data: GameData,
|
|
594
|
+
config: dict[str, Any],
|
|
595
|
+
) -> bool:
|
|
596
|
+
"""Spawn a zombie according to the stage's interior/exterior mix."""
|
|
597
|
+
stage = game_data.stage
|
|
598
|
+
|
|
599
|
+
def _spawn(choice: str) -> bool:
|
|
600
|
+
if choice == "interior":
|
|
601
|
+
return _spawn_nearby_zombie(game_data, config) is not None
|
|
602
|
+
return spawn_exterior_zombie(game_data, config) is not None
|
|
603
|
+
|
|
604
|
+
interior_weight = max(0.0, stage.interior_spawn_weight)
|
|
605
|
+
exterior_weight = max(0.0, stage.exterior_spawn_weight)
|
|
606
|
+
total_weight = interior_weight + exterior_weight
|
|
607
|
+
if total_weight <= 0:
|
|
608
|
+
# Fall back to exterior spawns if weights are unset or invalid.
|
|
609
|
+
return _spawn("exterior")
|
|
610
|
+
|
|
611
|
+
pick = RNG.uniform(0, total_weight)
|
|
612
|
+
if pick <= interior_weight:
|
|
613
|
+
if _spawn("interior"):
|
|
614
|
+
return True
|
|
615
|
+
return _spawn("exterior")
|
|
616
|
+
if _spawn("exterior"):
|
|
617
|
+
return True
|
|
618
|
+
return _spawn("interior")
|