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
zombie_escape/gameplay/logic.py
DELETED
|
@@ -1,1917 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from bisect import bisect_left
|
|
4
|
-
from typing import Any, Mapping, Sequence
|
|
5
|
-
|
|
6
|
-
import math
|
|
7
|
-
|
|
8
|
-
import pygame
|
|
9
|
-
|
|
10
|
-
from ..colors import (
|
|
11
|
-
DAWN_AMBIENT_PALETTE_KEY,
|
|
12
|
-
ambient_palette_key_for_flashlights,
|
|
13
|
-
get_environment_palette,
|
|
14
|
-
)
|
|
15
|
-
from ..gameplay_constants import (
|
|
16
|
-
CAR_HEIGHT,
|
|
17
|
-
CAR_SPEED,
|
|
18
|
-
CAR_WIDTH,
|
|
19
|
-
CAR_ZOMBIE_DAMAGE,
|
|
20
|
-
BUDDY_RADIUS,
|
|
21
|
-
DEFAULT_FLASHLIGHT_SPAWN_COUNT,
|
|
22
|
-
FAST_ZOMBIE_BASE_SPEED,
|
|
23
|
-
FLASHLIGHT_HEIGHT,
|
|
24
|
-
FLASHLIGHT_WIDTH,
|
|
25
|
-
FOOTPRINT_MAX,
|
|
26
|
-
FOOTPRINT_STEP_DISTANCE,
|
|
27
|
-
FUEL_CAN_HEIGHT,
|
|
28
|
-
FUEL_CAN_WIDTH,
|
|
29
|
-
FUEL_HINT_DURATION_MS,
|
|
30
|
-
INTERNAL_WALL_HEALTH,
|
|
31
|
-
MAX_ZOMBIES,
|
|
32
|
-
OUTER_WALL_HEALTH,
|
|
33
|
-
PLAYER_RADIUS,
|
|
34
|
-
PLAYER_SPEED,
|
|
35
|
-
STEEL_BEAM_HEALTH,
|
|
36
|
-
SURVIVOR_CONVERSION_LINE_KEYS,
|
|
37
|
-
SURVIVOR_APPROACH_RADIUS,
|
|
38
|
-
SURVIVOR_MAX_SAFE_PASSENGERS,
|
|
39
|
-
SURVIVOR_MESSAGE_DURATION_MS,
|
|
40
|
-
SURVIVOR_MIN_SPEED_FACTOR,
|
|
41
|
-
SURVIVOR_OVERLOAD_DAMAGE_RATIO,
|
|
42
|
-
SURVIVOR_RADIUS,
|
|
43
|
-
SURVIVOR_SPEED_PENALTY_PER_PASSENGER,
|
|
44
|
-
SURVIVOR_STAGE_WAITING_CAR_COUNT,
|
|
45
|
-
SURVIVAL_NEAR_SPAWN_CAMERA_MARGIN,
|
|
46
|
-
SURVIVAL_NEAR_SPAWN_MAX_DISTANCE,
|
|
47
|
-
SURVIVAL_NEAR_SPAWN_MIN_DISTANCE,
|
|
48
|
-
ZOMBIE_AGING_DURATION_FRAMES,
|
|
49
|
-
ZOMBIE_RADIUS,
|
|
50
|
-
ZOMBIE_SEPARATION_DISTANCE,
|
|
51
|
-
ZOMBIE_SPAWN_DELAY_MS,
|
|
52
|
-
ZOMBIE_SPAWN_PLAYER_BUFFER,
|
|
53
|
-
ZOMBIE_SPEED,
|
|
54
|
-
ZOMBIE_TRACKER_AGING_DURATION_FRAMES,
|
|
55
|
-
interaction_radius,
|
|
56
|
-
ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
|
|
57
|
-
)
|
|
58
|
-
from ..level_constants import CELL_SIZE, GRID_COLS, GRID_ROWS, LEVEL_HEIGHT, LEVEL_WIDTH
|
|
59
|
-
from ..screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
|
|
60
|
-
from ..localization import translate as tr
|
|
61
|
-
from ..level_blueprints import choose_blueprint
|
|
62
|
-
from ..models import Areas, GameData, Groups, ProgressState, Stage
|
|
63
|
-
from ..rng import get_rng
|
|
64
|
-
from ..entities import (
|
|
65
|
-
Camera,
|
|
66
|
-
Car,
|
|
67
|
-
Flashlight,
|
|
68
|
-
FuelCan,
|
|
69
|
-
Player,
|
|
70
|
-
SteelBeam,
|
|
71
|
-
Survivor,
|
|
72
|
-
Wall,
|
|
73
|
-
Zombie,
|
|
74
|
-
spritecollideany_walls,
|
|
75
|
-
walls_for_radius,
|
|
76
|
-
WallIndex,
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
LOGICAL_SCREEN_RECT = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
|
|
80
|
-
RNG = get_rng()
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def car_appearance_for_stage(stage: Stage | None) -> str:
|
|
84
|
-
return "disabled" if stage and stage.survival_stage else "default"
|
|
85
|
-
|
|
86
|
-
__all__ = [
|
|
87
|
-
"create_zombie",
|
|
88
|
-
"rect_for_cell",
|
|
89
|
-
"generate_level_from_blueprint",
|
|
90
|
-
"place_new_car",
|
|
91
|
-
"place_fuel_can",
|
|
92
|
-
"place_flashlight",
|
|
93
|
-
"place_flashlights",
|
|
94
|
-
"place_buddies",
|
|
95
|
-
"scatter_positions_on_walkable",
|
|
96
|
-
"spawn_survivors",
|
|
97
|
-
"spawn_nearby_zombie",
|
|
98
|
-
"spawn_exterior_zombie",
|
|
99
|
-
"spawn_weighted_zombie",
|
|
100
|
-
"update_survivors",
|
|
101
|
-
"alive_waiting_cars",
|
|
102
|
-
"log_waiting_car_count",
|
|
103
|
-
"nearest_waiting_car",
|
|
104
|
-
"calculate_car_speed_for_passengers",
|
|
105
|
-
"apply_passenger_speed_penalty",
|
|
106
|
-
"increase_survivor_capacity",
|
|
107
|
-
"waiting_car_target_count",
|
|
108
|
-
"spawn_waiting_car",
|
|
109
|
-
"maintain_waiting_car_supply",
|
|
110
|
-
"add_survivor_message",
|
|
111
|
-
"random_survivor_conversion_line",
|
|
112
|
-
"cleanup_survivor_messages",
|
|
113
|
-
"drop_survivors_from_car",
|
|
114
|
-
"handle_survivor_zombie_collisions",
|
|
115
|
-
"respawn_buddies_near_player",
|
|
116
|
-
"get_shrunk_sprite",
|
|
117
|
-
"update_footprints",
|
|
118
|
-
"initialize_game_state",
|
|
119
|
-
"setup_player_and_cars",
|
|
120
|
-
"spawn_initial_zombies",
|
|
121
|
-
"update_survival_timer",
|
|
122
|
-
"carbonize_outdoor_zombies",
|
|
123
|
-
"process_player_input",
|
|
124
|
-
"update_entities",
|
|
125
|
-
"check_interactions",
|
|
126
|
-
"set_ambient_palette",
|
|
127
|
-
"sync_ambient_palette_with_flashlights",
|
|
128
|
-
]
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def create_zombie(
|
|
132
|
-
config: dict[str, Any],
|
|
133
|
-
*,
|
|
134
|
-
start_pos: tuple[int, int] | None = None,
|
|
135
|
-
hint_pos: tuple[float, float] | None = None,
|
|
136
|
-
stage: Stage | None = None,
|
|
137
|
-
outer_wall_cells: set[tuple[int, int]] | None = None,
|
|
138
|
-
tracker: bool | None = None,
|
|
139
|
-
wall_follower: bool | None = None,
|
|
140
|
-
) -> Zombie:
|
|
141
|
-
"""Factory to create zombies with optional fast variants."""
|
|
142
|
-
fast_conf = config.get("fast_zombies", {})
|
|
143
|
-
fast_enabled = fast_conf.get("enabled", True)
|
|
144
|
-
if fast_enabled:
|
|
145
|
-
base_speed = RNG.uniform(ZOMBIE_SPEED, FAST_ZOMBIE_BASE_SPEED)
|
|
146
|
-
else:
|
|
147
|
-
base_speed = ZOMBIE_SPEED
|
|
148
|
-
base_speed = min(base_speed, PLAYER_SPEED - 0.05)
|
|
149
|
-
normal_ratio = 1.0
|
|
150
|
-
tracker_ratio = 0.0
|
|
151
|
-
wall_follower_ratio = 0.0
|
|
152
|
-
if stage is not None:
|
|
153
|
-
normal_ratio = max(0.0, min(1.0, getattr(stage, "zombie_normal_ratio", 1.0)))
|
|
154
|
-
tracker_ratio = max(0.0, min(1.0, getattr(stage, "zombie_tracker_ratio", 0.0)))
|
|
155
|
-
wall_follower_ratio = max(
|
|
156
|
-
0.0, min(1.0, getattr(stage, "zombie_wall_follower_ratio", 0.0))
|
|
157
|
-
)
|
|
158
|
-
if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
|
|
159
|
-
# Fall back to normal behavior if all ratios are zero.
|
|
160
|
-
normal_ratio = 1.0
|
|
161
|
-
tracker_ratio = 0.0
|
|
162
|
-
wall_follower_ratio = 0.0
|
|
163
|
-
if (
|
|
164
|
-
normal_ratio == 1.0
|
|
165
|
-
and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
|
|
166
|
-
and tracker_ratio + wall_follower_ratio <= 1.0
|
|
167
|
-
):
|
|
168
|
-
normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
|
|
169
|
-
aging_duration_frames = max(
|
|
170
|
-
1.0,
|
|
171
|
-
float(
|
|
172
|
-
getattr(
|
|
173
|
-
stage, "zombie_aging_duration_frames", ZOMBIE_AGING_DURATION_FRAMES
|
|
174
|
-
)
|
|
175
|
-
),
|
|
176
|
-
)
|
|
177
|
-
else:
|
|
178
|
-
aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
|
|
179
|
-
picked_tracker = False
|
|
180
|
-
picked_wall_follower = False
|
|
181
|
-
total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
|
|
182
|
-
if total_ratio > 0:
|
|
183
|
-
pick = RNG.random() * total_ratio
|
|
184
|
-
if pick < normal_ratio:
|
|
185
|
-
pass
|
|
186
|
-
elif pick < normal_ratio + tracker_ratio:
|
|
187
|
-
picked_tracker = True
|
|
188
|
-
else:
|
|
189
|
-
picked_wall_follower = True
|
|
190
|
-
if tracker is None:
|
|
191
|
-
tracker = picked_tracker
|
|
192
|
-
if wall_follower is None:
|
|
193
|
-
wall_follower = picked_wall_follower
|
|
194
|
-
if tracker:
|
|
195
|
-
wall_follower = False
|
|
196
|
-
if tracker:
|
|
197
|
-
ratio = (
|
|
198
|
-
ZOMBIE_TRACKER_AGING_DURATION_FRAMES / ZOMBIE_AGING_DURATION_FRAMES
|
|
199
|
-
if ZOMBIE_AGING_DURATION_FRAMES > 0
|
|
200
|
-
else 1.0
|
|
201
|
-
)
|
|
202
|
-
aging_duration_frames = max(1.0, aging_duration_frames * ratio)
|
|
203
|
-
return Zombie(
|
|
204
|
-
start_pos=start_pos,
|
|
205
|
-
hint_pos=hint_pos,
|
|
206
|
-
speed=base_speed,
|
|
207
|
-
tracker=tracker,
|
|
208
|
-
wall_follower=wall_follower,
|
|
209
|
-
aging_duration_frames=aging_duration_frames,
|
|
210
|
-
outer_wall_cells=outer_wall_cells,
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def rect_for_cell(x_idx: int, y_idx: int) -> pygame.Rect:
|
|
215
|
-
return pygame.Rect(x_idx * CELL_SIZE, y_idx * CELL_SIZE, CELL_SIZE, CELL_SIZE)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def generate_level_from_blueprint(
|
|
219
|
-
game_data: GameData, config: dict[str, Any]
|
|
220
|
-
) -> dict[str, list[pygame.Rect]]:
|
|
221
|
-
"""Build walls/spawn candidates/outside area from a blueprint grid."""
|
|
222
|
-
wall_group = game_data.groups.wall_group
|
|
223
|
-
all_sprites = game_data.groups.all_sprites
|
|
224
|
-
|
|
225
|
-
steel_conf = config.get("steel_beams", {})
|
|
226
|
-
steel_enabled = steel_conf.get("enabled", False)
|
|
227
|
-
|
|
228
|
-
blueprint_data = choose_blueprint(config)
|
|
229
|
-
if isinstance(blueprint_data, dict):
|
|
230
|
-
blueprint = blueprint_data.get("grid", [])
|
|
231
|
-
steel_cells_raw = blueprint_data.get("steel_cells", set())
|
|
232
|
-
else:
|
|
233
|
-
blueprint = blueprint_data
|
|
234
|
-
steel_cells_raw = set()
|
|
235
|
-
|
|
236
|
-
steel_cells = (
|
|
237
|
-
{(int(x), int(y)) for x, y in steel_cells_raw} if steel_enabled else set()
|
|
238
|
-
)
|
|
239
|
-
outer_wall_cells = {
|
|
240
|
-
(x, y)
|
|
241
|
-
for y, row in enumerate(blueprint)
|
|
242
|
-
for x, ch in enumerate(row)
|
|
243
|
-
if ch == "B"
|
|
244
|
-
}
|
|
245
|
-
wall_cells = {
|
|
246
|
-
(x, y)
|
|
247
|
-
for y, row in enumerate(blueprint)
|
|
248
|
-
for x, ch in enumerate(row)
|
|
249
|
-
if ch in {"B", "1"}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
def has_wall(nx: int, ny: int) -> bool:
|
|
253
|
-
if nx < 0 or ny < 0 or nx >= GRID_COLS or ny >= GRID_ROWS:
|
|
254
|
-
return True
|
|
255
|
-
return (nx, ny) in wall_cells
|
|
256
|
-
|
|
257
|
-
outside_rects: list[pygame.Rect] = []
|
|
258
|
-
walkable_cells: list[pygame.Rect] = []
|
|
259
|
-
player_cells: list[pygame.Rect] = []
|
|
260
|
-
car_cells: list[pygame.Rect] = []
|
|
261
|
-
zombie_cells: list[pygame.Rect] = []
|
|
262
|
-
palette = get_environment_palette(game_data.state.ambient_palette_key)
|
|
263
|
-
|
|
264
|
-
def add_beam_to_groups(beam: "SteelBeam") -> None:
|
|
265
|
-
if getattr(beam, "_added_to_groups", False):
|
|
266
|
-
return
|
|
267
|
-
wall_group.add(beam)
|
|
268
|
-
all_sprites.add(beam, layer=0)
|
|
269
|
-
beam._added_to_groups = True
|
|
270
|
-
|
|
271
|
-
for y, row in enumerate(blueprint):
|
|
272
|
-
if len(row) != GRID_COLS:
|
|
273
|
-
raise ValueError(
|
|
274
|
-
f"Blueprint width mismatch at row {y}: {len(row)} != {GRID_COLS}"
|
|
275
|
-
)
|
|
276
|
-
for x, ch in enumerate(row):
|
|
277
|
-
cell_rect = rect_for_cell(x, y)
|
|
278
|
-
cell_has_beam = steel_enabled and (x, y) in steel_cells
|
|
279
|
-
if ch == "O":
|
|
280
|
-
outside_rects.append(cell_rect)
|
|
281
|
-
continue
|
|
282
|
-
if ch == "B":
|
|
283
|
-
draw_bottom_side = not has_wall(x, y + 1)
|
|
284
|
-
wall = Wall(
|
|
285
|
-
cell_rect.x,
|
|
286
|
-
cell_rect.y,
|
|
287
|
-
cell_rect.width,
|
|
288
|
-
cell_rect.height,
|
|
289
|
-
health=OUTER_WALL_HEALTH,
|
|
290
|
-
color=palette.outer_wall,
|
|
291
|
-
border_color=palette.outer_wall_border,
|
|
292
|
-
palette_category="outer_wall",
|
|
293
|
-
bevel_depth=0,
|
|
294
|
-
draw_bottom_side=draw_bottom_side,
|
|
295
|
-
)
|
|
296
|
-
wall_group.add(wall)
|
|
297
|
-
all_sprites.add(wall, layer=0)
|
|
298
|
-
continue
|
|
299
|
-
if ch == "E":
|
|
300
|
-
if not cell_has_beam:
|
|
301
|
-
walkable_cells.append(cell_rect)
|
|
302
|
-
elif ch == "1":
|
|
303
|
-
beam = None
|
|
304
|
-
if cell_has_beam:
|
|
305
|
-
beam = SteelBeam(
|
|
306
|
-
cell_rect.x,
|
|
307
|
-
cell_rect.y,
|
|
308
|
-
cell_rect.width,
|
|
309
|
-
health=STEEL_BEAM_HEALTH,
|
|
310
|
-
)
|
|
311
|
-
draw_bottom_side = not has_wall(x, y + 1)
|
|
312
|
-
bevel_mask = (
|
|
313
|
-
not has_wall(x, y - 1)
|
|
314
|
-
and not has_wall(x - 1, y)
|
|
315
|
-
and not has_wall(x - 1, y - 1),
|
|
316
|
-
not has_wall(x, y - 1)
|
|
317
|
-
and not has_wall(x + 1, y)
|
|
318
|
-
and not has_wall(x + 1, y - 1),
|
|
319
|
-
not has_wall(x, y + 1)
|
|
320
|
-
and not has_wall(x + 1, y)
|
|
321
|
-
and not has_wall(x + 1, y + 1),
|
|
322
|
-
not has_wall(x, y + 1)
|
|
323
|
-
and not has_wall(x - 1, y)
|
|
324
|
-
and not has_wall(x - 1, y + 1),
|
|
325
|
-
)
|
|
326
|
-
wall = Wall(
|
|
327
|
-
cell_rect.x,
|
|
328
|
-
cell_rect.y,
|
|
329
|
-
cell_rect.width,
|
|
330
|
-
cell_rect.height,
|
|
331
|
-
health=INTERNAL_WALL_HEALTH,
|
|
332
|
-
color=palette.inner_wall,
|
|
333
|
-
border_color=palette.inner_wall_border,
|
|
334
|
-
palette_category="inner_wall",
|
|
335
|
-
bevel_mask=bevel_mask,
|
|
336
|
-
draw_bottom_side=draw_bottom_side,
|
|
337
|
-
on_destroy=(lambda _w, b=beam: add_beam_to_groups(b))
|
|
338
|
-
if beam
|
|
339
|
-
else None,
|
|
340
|
-
)
|
|
341
|
-
wall_group.add(wall)
|
|
342
|
-
all_sprites.add(wall, layer=0)
|
|
343
|
-
# Embedded beams stay hidden until the wall is destroyed
|
|
344
|
-
else:
|
|
345
|
-
if not cell_has_beam:
|
|
346
|
-
walkable_cells.append(cell_rect)
|
|
347
|
-
|
|
348
|
-
if ch == "P":
|
|
349
|
-
player_cells.append(cell_rect)
|
|
350
|
-
if ch == "C":
|
|
351
|
-
car_cells.append(cell_rect)
|
|
352
|
-
if ch == "Z":
|
|
353
|
-
zombie_cells.append(cell_rect)
|
|
354
|
-
|
|
355
|
-
# Standalone beams (non-wall cells) are placed immediately
|
|
356
|
-
if cell_has_beam and ch != "1":
|
|
357
|
-
beam = SteelBeam(
|
|
358
|
-
cell_rect.x, cell_rect.y, cell_rect.width, health=STEEL_BEAM_HEALTH
|
|
359
|
-
)
|
|
360
|
-
add_beam_to_groups(beam)
|
|
361
|
-
|
|
362
|
-
game_data.areas.outer_rect = (0, 0, LEVEL_WIDTH, LEVEL_HEIGHT)
|
|
363
|
-
game_data.areas.inner_rect = (0, 0, LEVEL_WIDTH, LEVEL_HEIGHT)
|
|
364
|
-
game_data.areas.outside_rects = outside_rects
|
|
365
|
-
game_data.areas.walkable_cells = walkable_cells
|
|
366
|
-
game_data.areas.outer_wall_cells = outer_wall_cells
|
|
367
|
-
# level_rect no longer used
|
|
368
|
-
|
|
369
|
-
return {
|
|
370
|
-
"player_cells": player_cells,
|
|
371
|
-
"car_cells": car_cells,
|
|
372
|
-
"zombie_cells": zombie_cells,
|
|
373
|
-
"walkable_cells": walkable_cells,
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
def place_new_car(
|
|
378
|
-
wall_group: pygame.sprite.Group,
|
|
379
|
-
player: Player,
|
|
380
|
-
walkable_cells: list[pygame.Rect],
|
|
381
|
-
*,
|
|
382
|
-
existing_cars: Sequence[Car] | None = None,
|
|
383
|
-
appearance: str = "default",
|
|
384
|
-
) -> Car | None:
|
|
385
|
-
if not walkable_cells:
|
|
386
|
-
return None
|
|
387
|
-
|
|
388
|
-
max_attempts = 150
|
|
389
|
-
for attempt in range(max_attempts):
|
|
390
|
-
cell = RNG.choice(walkable_cells)
|
|
391
|
-
c_x, c_y = cell.center
|
|
392
|
-
temp_car = Car(c_x, c_y, appearance=appearance)
|
|
393
|
-
temp_rect = temp_car.rect.inflate(30, 30)
|
|
394
|
-
nearby_walls = pygame.sprite.Group()
|
|
395
|
-
nearby_walls.add(
|
|
396
|
-
[
|
|
397
|
-
w
|
|
398
|
-
for w in wall_group
|
|
399
|
-
if abs(w.rect.centerx - c_x) < 150 and abs(w.rect.centery - c_y) < 150
|
|
400
|
-
]
|
|
401
|
-
)
|
|
402
|
-
collides_wall = spritecollideany_walls(temp_car, nearby_walls)
|
|
403
|
-
collides_player = temp_rect.colliderect(player.rect.inflate(50, 50))
|
|
404
|
-
car_overlap = False
|
|
405
|
-
if existing_cars:
|
|
406
|
-
car_overlap = any(
|
|
407
|
-
temp_car.rect.colliderect(other.rect)
|
|
408
|
-
for other in existing_cars
|
|
409
|
-
if other and other.alive()
|
|
410
|
-
)
|
|
411
|
-
if not collides_wall and not collides_player and not car_overlap:
|
|
412
|
-
return temp_car
|
|
413
|
-
return None
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def place_fuel_can(
|
|
417
|
-
walkable_cells: list[pygame.Rect],
|
|
418
|
-
player: Player,
|
|
419
|
-
*,
|
|
420
|
-
cars: Sequence[Car] | None = None,
|
|
421
|
-
count: int = 1,
|
|
422
|
-
) -> FuelCan | None:
|
|
423
|
-
"""Pick a spawn spot for the fuel can away from the player (and car if given)."""
|
|
424
|
-
if count <= 0 or not walkable_cells:
|
|
425
|
-
return None
|
|
426
|
-
|
|
427
|
-
min_player_dist = 250
|
|
428
|
-
min_car_dist = 200
|
|
429
|
-
|
|
430
|
-
for attempt in range(200):
|
|
431
|
-
cell = RNG.choice(walkable_cells)
|
|
432
|
-
if (
|
|
433
|
-
math.hypot(cell.centerx - player.x, cell.centery - player.y)
|
|
434
|
-
< min_player_dist
|
|
435
|
-
):
|
|
436
|
-
continue
|
|
437
|
-
if cars:
|
|
438
|
-
too_close = False
|
|
439
|
-
for parked_car in cars:
|
|
440
|
-
if math.hypot(
|
|
441
|
-
cell.centerx - parked_car.rect.centerx,
|
|
442
|
-
cell.centery - parked_car.rect.centery,
|
|
443
|
-
) < min_car_dist:
|
|
444
|
-
too_close = True
|
|
445
|
-
break
|
|
446
|
-
if too_close:
|
|
447
|
-
continue
|
|
448
|
-
return FuelCan(cell.centerx, cell.centery)
|
|
449
|
-
|
|
450
|
-
# Fallback: drop near a random walkable cell
|
|
451
|
-
cell = RNG.choice(walkable_cells)
|
|
452
|
-
return FuelCan(cell.centerx, cell.centery)
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def place_flashlight(
|
|
456
|
-
walkable_cells: list[pygame.Rect],
|
|
457
|
-
player: Player,
|
|
458
|
-
*,
|
|
459
|
-
cars: Sequence[Car] | None = None,
|
|
460
|
-
) -> Flashlight | None:
|
|
461
|
-
"""Pick a spawn spot for the flashlight away from the player (and car if given)."""
|
|
462
|
-
if not walkable_cells:
|
|
463
|
-
return None
|
|
464
|
-
|
|
465
|
-
min_player_dist = 260
|
|
466
|
-
min_car_dist = 200
|
|
467
|
-
|
|
468
|
-
for attempt in range(200):
|
|
469
|
-
cell = RNG.choice(walkable_cells)
|
|
470
|
-
if (
|
|
471
|
-
math.hypot(cell.centerx - player.x, cell.centery - player.y)
|
|
472
|
-
< min_player_dist
|
|
473
|
-
):
|
|
474
|
-
continue
|
|
475
|
-
if cars:
|
|
476
|
-
if any(
|
|
477
|
-
math.hypot(
|
|
478
|
-
cell.centerx - parked.rect.centerx,
|
|
479
|
-
cell.centery - parked.rect.centery,
|
|
480
|
-
)
|
|
481
|
-
< min_car_dist
|
|
482
|
-
for parked in cars
|
|
483
|
-
):
|
|
484
|
-
continue
|
|
485
|
-
return Flashlight(cell.centerx, cell.centery)
|
|
486
|
-
|
|
487
|
-
cell = RNG.choice(walkable_cells)
|
|
488
|
-
return Flashlight(cell.centerx, cell.centery)
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
def place_flashlights(
|
|
492
|
-
walkable_cells: list[pygame.Rect],
|
|
493
|
-
player: Player,
|
|
494
|
-
*,
|
|
495
|
-
cars: Sequence[Car] | None = None,
|
|
496
|
-
count: int = DEFAULT_FLASHLIGHT_SPAWN_COUNT,
|
|
497
|
-
) -> list[Flashlight]:
|
|
498
|
-
"""Spawn multiple flashlights using the single-place helper to spread them out."""
|
|
499
|
-
placed: list[Flashlight] = []
|
|
500
|
-
attempts = 0
|
|
501
|
-
max_attempts = max(200, count * 80)
|
|
502
|
-
while len(placed) < count and attempts < max_attempts:
|
|
503
|
-
attempts += 1
|
|
504
|
-
fl = place_flashlight(walkable_cells, player, cars=cars)
|
|
505
|
-
if not fl:
|
|
506
|
-
break
|
|
507
|
-
# Avoid clustering too tightly
|
|
508
|
-
if any(
|
|
509
|
-
math.hypot(
|
|
510
|
-
other.rect.centerx - fl.rect.centerx,
|
|
511
|
-
other.rect.centery - fl.rect.centery,
|
|
512
|
-
)
|
|
513
|
-
< 120
|
|
514
|
-
for other in placed
|
|
515
|
-
):
|
|
516
|
-
continue
|
|
517
|
-
placed.append(fl)
|
|
518
|
-
return placed
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
def place_buddy(
|
|
522
|
-
walkable_cells: list[pygame.Rect],
|
|
523
|
-
player: Player,
|
|
524
|
-
*,
|
|
525
|
-
cars: Sequence[Car] | None = None,
|
|
526
|
-
) -> Survivor | None:
|
|
527
|
-
"""Spawn the stranded buddy somewhere on a walkable tile away from the player and car."""
|
|
528
|
-
if not walkable_cells:
|
|
529
|
-
return None
|
|
530
|
-
|
|
531
|
-
min_player_dist = 240
|
|
532
|
-
min_car_dist = 180
|
|
533
|
-
|
|
534
|
-
for attempt in range(200):
|
|
535
|
-
cell = RNG.choice(walkable_cells)
|
|
536
|
-
if (
|
|
537
|
-
math.hypot(cell.centerx - player.x, cell.centery - player.y)
|
|
538
|
-
< min_player_dist
|
|
539
|
-
):
|
|
540
|
-
continue
|
|
541
|
-
if cars:
|
|
542
|
-
if any(
|
|
543
|
-
math.hypot(
|
|
544
|
-
cell.centerx - parked.rect.centerx,
|
|
545
|
-
cell.centery - parked.rect.centery,
|
|
546
|
-
)
|
|
547
|
-
< min_car_dist
|
|
548
|
-
for parked in cars
|
|
549
|
-
):
|
|
550
|
-
continue
|
|
551
|
-
return Survivor(cell.centerx, cell.centery, is_buddy=True)
|
|
552
|
-
|
|
553
|
-
cell = RNG.choice(walkable_cells)
|
|
554
|
-
return Survivor(cell.centerx, cell.centery, is_buddy=True)
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
def place_buddies(
|
|
558
|
-
walkable_cells: list[pygame.Rect],
|
|
559
|
-
player: Player,
|
|
560
|
-
*,
|
|
561
|
-
cars: Sequence[Car] | None = None,
|
|
562
|
-
count: int = 1,
|
|
563
|
-
) -> list[Survivor]:
|
|
564
|
-
placed: list[Survivor] = []
|
|
565
|
-
if count <= 0:
|
|
566
|
-
return placed
|
|
567
|
-
attempts = 0
|
|
568
|
-
max_attempts = max(200, count * 60)
|
|
569
|
-
while len(placed) < count and attempts < max_attempts:
|
|
570
|
-
attempts += 1
|
|
571
|
-
buddy = place_buddy(walkable_cells, player, cars=cars)
|
|
572
|
-
if not buddy:
|
|
573
|
-
break
|
|
574
|
-
if any(
|
|
575
|
-
math.hypot(
|
|
576
|
-
other.rect.centerx - buddy.rect.centerx,
|
|
577
|
-
other.rect.centery - buddy.rect.centery,
|
|
578
|
-
)
|
|
579
|
-
< 100
|
|
580
|
-
for other in placed
|
|
581
|
-
):
|
|
582
|
-
continue
|
|
583
|
-
placed.append(buddy)
|
|
584
|
-
return placed
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
def scatter_positions_on_walkable(
|
|
588
|
-
walkable_cells: list[pygame.Rect],
|
|
589
|
-
spawn_rate: float,
|
|
590
|
-
*,
|
|
591
|
-
jitter_ratio: float = 0.35,
|
|
592
|
-
) -> list[tuple[int, int]]:
|
|
593
|
-
positions: list[tuple[int, int]] = []
|
|
594
|
-
if not walkable_cells or spawn_rate <= 0:
|
|
595
|
-
return positions
|
|
596
|
-
|
|
597
|
-
clamped_rate = max(0.0, min(1.0, spawn_rate))
|
|
598
|
-
for cell in walkable_cells:
|
|
599
|
-
if RNG.random() >= clamped_rate:
|
|
600
|
-
continue
|
|
601
|
-
jitter_x = RNG.uniform(-cell.width * jitter_ratio, cell.width * jitter_ratio)
|
|
602
|
-
jitter_y = RNG.uniform(
|
|
603
|
-
-cell.height * jitter_ratio, cell.height * jitter_ratio
|
|
604
|
-
)
|
|
605
|
-
positions.append((int(cell.centerx + jitter_x), int(cell.centery + jitter_y)))
|
|
606
|
-
return positions
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
def spawn_survivors(
|
|
610
|
-
game_data: GameData, layout_data: Mapping[str, list[pygame.Rect]]
|
|
611
|
-
) -> list[Survivor]:
|
|
612
|
-
"""Populate rescue-stage survivors and buddy-stage buddies."""
|
|
613
|
-
survivors: list[Survivor] = []
|
|
614
|
-
if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
|
|
615
|
-
return survivors
|
|
616
|
-
|
|
617
|
-
walkable = layout_data.get("walkable_cells", [])
|
|
618
|
-
wall_group = game_data.groups.wall_group
|
|
619
|
-
survivor_group = game_data.groups.survivor_group
|
|
620
|
-
all_sprites = game_data.groups.all_sprites
|
|
621
|
-
|
|
622
|
-
if game_data.stage.rescue_stage:
|
|
623
|
-
for pos in scatter_positions_on_walkable(
|
|
624
|
-
walkable, game_data.stage.survivor_spawn_rate
|
|
625
|
-
):
|
|
626
|
-
s = Survivor(*pos)
|
|
627
|
-
if spritecollideany_walls(s, wall_group):
|
|
628
|
-
continue
|
|
629
|
-
survivor_group.add(s)
|
|
630
|
-
all_sprites.add(s, layer=1)
|
|
631
|
-
survivors.append(s)
|
|
632
|
-
|
|
633
|
-
if game_data.stage.buddy_required_count > 0:
|
|
634
|
-
buddy_count = max(0, game_data.stage.buddy_required_count)
|
|
635
|
-
buddies: list[Survivor] = []
|
|
636
|
-
if game_data.player:
|
|
637
|
-
buddies = place_buddies(
|
|
638
|
-
walkable,
|
|
639
|
-
game_data.player,
|
|
640
|
-
cars=game_data.waiting_cars,
|
|
641
|
-
count=buddy_count,
|
|
642
|
-
)
|
|
643
|
-
for buddy in buddies:
|
|
644
|
-
if spritecollideany_walls(buddy, wall_group):
|
|
645
|
-
continue
|
|
646
|
-
survivor_group.add(buddy)
|
|
647
|
-
all_sprites.add(buddy, layer=2)
|
|
648
|
-
survivors.append(buddy)
|
|
649
|
-
|
|
650
|
-
return survivors
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
def update_survivors(
|
|
654
|
-
game_data: GameData, wall_index: WallIndex | None = None
|
|
655
|
-
) -> None:
|
|
656
|
-
if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
|
|
657
|
-
return
|
|
658
|
-
survivor_group = game_data.groups.survivor_group
|
|
659
|
-
wall_group = game_data.groups.wall_group
|
|
660
|
-
player = game_data.player
|
|
661
|
-
car = game_data.car
|
|
662
|
-
if not player:
|
|
663
|
-
return
|
|
664
|
-
target_rect = car.rect if player.in_car and car and car.alive() else player.rect
|
|
665
|
-
target_pos = target_rect.center
|
|
666
|
-
survivors = [s for s in survivor_group if s.alive()]
|
|
667
|
-
for survivor in survivors:
|
|
668
|
-
survivor.update_behavior(target_pos, wall_group, wall_index=wall_index)
|
|
669
|
-
|
|
670
|
-
# Gently prevent survivors from overlapping the player or each other
|
|
671
|
-
def _separate_from_point(
|
|
672
|
-
survivor: Survivor, point: tuple[float, float], min_dist: float
|
|
673
|
-
) -> None:
|
|
674
|
-
dx = point[0] - survivor.x
|
|
675
|
-
dy = point[1] - survivor.y
|
|
676
|
-
dist = math.hypot(dx, dy)
|
|
677
|
-
if dist == 0:
|
|
678
|
-
angle = RNG.uniform(0, math.tau)
|
|
679
|
-
dx, dy = math.cos(angle), math.sin(angle)
|
|
680
|
-
dist = 1
|
|
681
|
-
if dist < min_dist:
|
|
682
|
-
push = min_dist - dist
|
|
683
|
-
survivor.x -= (dx / dist) * push
|
|
684
|
-
survivor.y -= (dy / dist) * push
|
|
685
|
-
survivor.rect.center = (int(survivor.x), int(survivor.y))
|
|
686
|
-
|
|
687
|
-
player_overlap = (SURVIVOR_RADIUS + PLAYER_RADIUS) * 1.05
|
|
688
|
-
survivor_overlap = (SURVIVOR_RADIUS * 2) * 1.05
|
|
689
|
-
|
|
690
|
-
player_point = (player.x, player.y)
|
|
691
|
-
for survivor in survivors:
|
|
692
|
-
_separate_from_point(survivor, player_point, player_overlap)
|
|
693
|
-
|
|
694
|
-
survivors_with_x = sorted(
|
|
695
|
-
((survivor.x, survivor) for survivor in survivors), key=lambda item: item[0]
|
|
696
|
-
)
|
|
697
|
-
for i, (base_x, survivor) in enumerate(survivors_with_x):
|
|
698
|
-
for other_base_x, other in survivors_with_x[i + 1 :]:
|
|
699
|
-
if other_base_x - base_x > survivor_overlap:
|
|
700
|
-
break
|
|
701
|
-
dx = other.x - survivor.x
|
|
702
|
-
dy = other.y - survivor.y
|
|
703
|
-
dist = math.hypot(dx, dy)
|
|
704
|
-
if dist == 0:
|
|
705
|
-
angle = RNG.uniform(0, math.tau)
|
|
706
|
-
dx, dy = math.cos(angle), math.sin(angle)
|
|
707
|
-
dist = 1
|
|
708
|
-
if dist < survivor_overlap:
|
|
709
|
-
push = (survivor_overlap - dist) / 2
|
|
710
|
-
offset_x = (dx / dist) * push
|
|
711
|
-
offset_y = (dy / dist) * push
|
|
712
|
-
survivor.x -= offset_x
|
|
713
|
-
survivor.y -= offset_y
|
|
714
|
-
other.x += offset_x
|
|
715
|
-
other.y += offset_y
|
|
716
|
-
survivor.rect.center = (int(survivor.x), int(survivor.y))
|
|
717
|
-
other.rect.center = (int(other.x), int(other.y))
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
def calculate_car_speed_for_passengers(
|
|
721
|
-
passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
|
|
722
|
-
) -> float:
|
|
723
|
-
cap = max(1, capacity)
|
|
724
|
-
load_ratio = max(0.0, passengers / cap)
|
|
725
|
-
penalty = SURVIVOR_SPEED_PENALTY_PER_PASSENGER * load_ratio
|
|
726
|
-
penalty = min(0.95, max(0.0, penalty))
|
|
727
|
-
adjusted = CAR_SPEED * (1 - penalty)
|
|
728
|
-
if passengers <= cap:
|
|
729
|
-
return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, adjusted)
|
|
730
|
-
|
|
731
|
-
overload = passengers - cap
|
|
732
|
-
overload_factor = 1 / math.sqrt(overload + 1)
|
|
733
|
-
overloaded_speed = CAR_SPEED * overload_factor
|
|
734
|
-
return max(CAR_SPEED * SURVIVOR_MIN_SPEED_FACTOR, overloaded_speed)
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
def apply_passenger_speed_penalty(game_data: GameData) -> None:
|
|
738
|
-
car = game_data.car
|
|
739
|
-
if not car:
|
|
740
|
-
return
|
|
741
|
-
if not game_data.stage.rescue_stage:
|
|
742
|
-
car.speed = CAR_SPEED
|
|
743
|
-
return
|
|
744
|
-
car.speed = calculate_car_speed_for_passengers(
|
|
745
|
-
game_data.state.survivors_onboard,
|
|
746
|
-
capacity=game_data.state.survivor_capacity,
|
|
747
|
-
)
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
def increase_survivor_capacity(game_data: GameData, increments: int = 1) -> None:
|
|
751
|
-
if increments <= 0:
|
|
752
|
-
return
|
|
753
|
-
if not game_data.stage.rescue_stage:
|
|
754
|
-
return
|
|
755
|
-
state = game_data.state
|
|
756
|
-
state.survivor_capacity += increments * SURVIVOR_MAX_SAFE_PASSENGERS
|
|
757
|
-
apply_passenger_speed_penalty(game_data)
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
def rect_visible_on_screen(camera: Camera | None, rect: pygame.Rect) -> bool:
|
|
761
|
-
if camera is None:
|
|
762
|
-
return False
|
|
763
|
-
return camera.apply_rect(rect).colliderect(LOGICAL_SCREEN_RECT)
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
def waiting_car_target_count(stage: Stage) -> int:
|
|
767
|
-
return SURVIVOR_STAGE_WAITING_CAR_COUNT if stage.rescue_stage else 1
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
def spawn_waiting_car(game_data: GameData) -> Car | None:
|
|
771
|
-
"""Attempt to place an additional parked car on the map."""
|
|
772
|
-
player = game_data.player
|
|
773
|
-
if not player:
|
|
774
|
-
return None
|
|
775
|
-
walkable_cells = game_data.areas.walkable_cells
|
|
776
|
-
if not walkable_cells:
|
|
777
|
-
return None
|
|
778
|
-
wall_group = game_data.groups.wall_group
|
|
779
|
-
all_sprites = game_data.groups.all_sprites
|
|
780
|
-
active_car = game_data.car if game_data.car and game_data.car.alive() else None
|
|
781
|
-
waiting = alive_waiting_cars(game_data)
|
|
782
|
-
obstacles: list[Car] = list(waiting)
|
|
783
|
-
if active_car:
|
|
784
|
-
obstacles.append(active_car)
|
|
785
|
-
camera = game_data.camera
|
|
786
|
-
appearance = car_appearance_for_stage(game_data.stage)
|
|
787
|
-
offscreen_attempts = 6
|
|
788
|
-
while offscreen_attempts > 0:
|
|
789
|
-
new_car = place_new_car(
|
|
790
|
-
wall_group,
|
|
791
|
-
player,
|
|
792
|
-
walkable_cells,
|
|
793
|
-
existing_cars=obstacles,
|
|
794
|
-
appearance=appearance,
|
|
795
|
-
)
|
|
796
|
-
if not new_car:
|
|
797
|
-
return None
|
|
798
|
-
if rect_visible_on_screen(camera, new_car.rect):
|
|
799
|
-
offscreen_attempts -= 1
|
|
800
|
-
continue
|
|
801
|
-
game_data.waiting_cars.append(new_car)
|
|
802
|
-
all_sprites.add(new_car, layer=1)
|
|
803
|
-
return new_car
|
|
804
|
-
return None
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
def maintain_waiting_car_supply(
|
|
808
|
-
game_data: GameData, *, minimum: int | None = None
|
|
809
|
-
) -> None:
|
|
810
|
-
"""Ensure a baseline count of parked cars exists."""
|
|
811
|
-
target = 1 if minimum is None else max(0, minimum)
|
|
812
|
-
current = len(alive_waiting_cars(game_data))
|
|
813
|
-
while current < target:
|
|
814
|
-
new_car = spawn_waiting_car(game_data)
|
|
815
|
-
if not new_car:
|
|
816
|
-
break
|
|
817
|
-
current += 1
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
def alive_waiting_cars(game_data: GameData) -> list[Car]:
|
|
821
|
-
"""Return the list of parked cars that still exist, pruning any destroyed sprites."""
|
|
822
|
-
cars = [car for car in game_data.waiting_cars if car.alive()]
|
|
823
|
-
game_data.waiting_cars = cars
|
|
824
|
-
log_waiting_car_count(game_data)
|
|
825
|
-
return cars
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
def log_waiting_car_count(game_data: GameData, *, force: bool = False) -> None:
|
|
829
|
-
"""Print the number of waiting cars when it changes."""
|
|
830
|
-
current = len(game_data.waiting_cars)
|
|
831
|
-
if not force and current == game_data.last_logged_waiting_cars:
|
|
832
|
-
return
|
|
833
|
-
game_data.last_logged_waiting_cars = current
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
def nearest_waiting_car(
|
|
837
|
-
game_data: GameData, origin: tuple[float, float]
|
|
838
|
-
) -> Car | None:
|
|
839
|
-
"""Find the closest waiting car to an origin point."""
|
|
840
|
-
cars = alive_waiting_cars(game_data)
|
|
841
|
-
if not cars:
|
|
842
|
-
return None
|
|
843
|
-
return min(
|
|
844
|
-
cars,
|
|
845
|
-
key=lambda car: math.hypot(car.rect.centerx - origin[0], car.rect.centery - origin[1]),
|
|
846
|
-
)
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
def add_survivor_message(game_data: GameData, text: str) -> None:
|
|
850
|
-
expires = pygame.time.get_ticks() + SURVIVOR_MESSAGE_DURATION_MS
|
|
851
|
-
game_data.state.survivor_messages.append({"text": text, "expires_at": expires})
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
def random_survivor_conversion_line() -> str:
|
|
855
|
-
if not SURVIVOR_CONVERSION_LINE_KEYS:
|
|
856
|
-
return ""
|
|
857
|
-
key = RNG.choice(SURVIVOR_CONVERSION_LINE_KEYS)
|
|
858
|
-
return tr(key)
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
def cleanup_survivor_messages(state: ProgressState) -> None:
|
|
862
|
-
now = pygame.time.get_ticks()
|
|
863
|
-
state.survivor_messages = [
|
|
864
|
-
msg for msg in state.survivor_messages if msg.get("expires_at", 0) > now
|
|
865
|
-
]
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
def drop_survivors_from_car(game_data: GameData, origin: tuple[int, int]) -> None:
|
|
869
|
-
"""Respawn boarded survivors back into the world after a crash."""
|
|
870
|
-
count = game_data.state.survivors_onboard
|
|
871
|
-
if count <= 0:
|
|
872
|
-
return
|
|
873
|
-
wall_group = game_data.groups.wall_group
|
|
874
|
-
survivor_group = game_data.groups.survivor_group
|
|
875
|
-
all_sprites = game_data.groups.all_sprites
|
|
876
|
-
|
|
877
|
-
for survivor_idx in range(count):
|
|
878
|
-
placed = False
|
|
879
|
-
for attempt in range(6):
|
|
880
|
-
angle = RNG.uniform(0, math.tau)
|
|
881
|
-
dist = RNG.uniform(16, 40)
|
|
882
|
-
pos = (
|
|
883
|
-
origin[0] + math.cos(angle) * dist,
|
|
884
|
-
origin[1] + math.sin(angle) * dist,
|
|
885
|
-
)
|
|
886
|
-
s = Survivor(*pos)
|
|
887
|
-
if not spritecollideany_walls(s, wall_group):
|
|
888
|
-
survivor_group.add(s)
|
|
889
|
-
all_sprites.add(s, layer=1)
|
|
890
|
-
placed = True
|
|
891
|
-
break
|
|
892
|
-
if not placed:
|
|
893
|
-
s = Survivor(*origin)
|
|
894
|
-
survivor_group.add(s)
|
|
895
|
-
all_sprites.add(s, layer=1)
|
|
896
|
-
|
|
897
|
-
game_data.state.survivors_onboard = 0
|
|
898
|
-
apply_passenger_speed_penalty(game_data)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
def handle_survivor_zombie_collisions(
|
|
902
|
-
game_data: GameData, config: dict[str, Any]
|
|
903
|
-
) -> None:
|
|
904
|
-
if not game_data.stage.rescue_stage:
|
|
905
|
-
return
|
|
906
|
-
survivor_group = game_data.groups.survivor_group
|
|
907
|
-
if not survivor_group:
|
|
908
|
-
return
|
|
909
|
-
zombie_group = game_data.groups.zombie_group
|
|
910
|
-
zombies = [z for z in zombie_group if z.alive()]
|
|
911
|
-
if not zombies:
|
|
912
|
-
return
|
|
913
|
-
zombies.sort(key=lambda s: s.rect.centerx)
|
|
914
|
-
zombie_xs = [z.rect.centerx for z in zombies]
|
|
915
|
-
camera = game_data.camera
|
|
916
|
-
walkable_cells = game_data.areas.walkable_cells
|
|
917
|
-
|
|
918
|
-
for survivor in list(survivor_group):
|
|
919
|
-
if not survivor.alive():
|
|
920
|
-
continue
|
|
921
|
-
survivor_radius = survivor.radius
|
|
922
|
-
search_radius = survivor_radius + ZOMBIE_RADIUS
|
|
923
|
-
search_radius_sq = search_radius * search_radius
|
|
924
|
-
|
|
925
|
-
min_x = survivor.rect.centerx - search_radius
|
|
926
|
-
max_x = survivor.rect.centerx + search_radius
|
|
927
|
-
start_idx = bisect_left(zombie_xs, min_x)
|
|
928
|
-
collided = False
|
|
929
|
-
for idx in range(start_idx, len(zombies)):
|
|
930
|
-
zombie_x = zombie_xs[idx]
|
|
931
|
-
if zombie_x > max_x:
|
|
932
|
-
break
|
|
933
|
-
zombie = zombies[idx]
|
|
934
|
-
if not zombie.alive():
|
|
935
|
-
continue
|
|
936
|
-
dy = zombie.rect.centery - survivor.rect.centery
|
|
937
|
-
if abs(dy) > search_radius:
|
|
938
|
-
continue
|
|
939
|
-
dx = zombie_x - survivor.rect.centerx
|
|
940
|
-
if dx * dx + dy * dy <= search_radius_sq:
|
|
941
|
-
collided = True
|
|
942
|
-
break
|
|
943
|
-
|
|
944
|
-
if not collided:
|
|
945
|
-
continue
|
|
946
|
-
if not rect_visible_on_screen(camera, survivor.rect):
|
|
947
|
-
if walkable_cells:
|
|
948
|
-
new_cell = RNG.choice(walkable_cells)
|
|
949
|
-
survivor.teleport(new_cell.center)
|
|
950
|
-
else:
|
|
951
|
-
survivor.teleport((LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2))
|
|
952
|
-
continue
|
|
953
|
-
survivor.kill()
|
|
954
|
-
line = random_survivor_conversion_line()
|
|
955
|
-
if line:
|
|
956
|
-
add_survivor_message(game_data, line)
|
|
957
|
-
new_zombie = create_zombie(
|
|
958
|
-
config,
|
|
959
|
-
start_pos=survivor.rect.center,
|
|
960
|
-
stage=game_data.stage,
|
|
961
|
-
outer_wall_cells=game_data.areas.outer_wall_cells,
|
|
962
|
-
)
|
|
963
|
-
zombie_group.add(new_zombie)
|
|
964
|
-
game_data.groups.all_sprites.add(new_zombie, layer=1)
|
|
965
|
-
insert_idx = bisect_left(zombie_xs, new_zombie.rect.centerx)
|
|
966
|
-
zombie_xs.insert(insert_idx, new_zombie.rect.centerx)
|
|
967
|
-
zombies.insert(insert_idx, new_zombie)
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
def respawn_buddies_near_player(game_data: GameData) -> None:
|
|
971
|
-
"""Bring back onboard buddies near the player after losing the car."""
|
|
972
|
-
if game_data.stage.buddy_required_count <= 0:
|
|
973
|
-
return
|
|
974
|
-
count = game_data.state.buddy_onboard
|
|
975
|
-
if count <= 0:
|
|
976
|
-
return
|
|
977
|
-
|
|
978
|
-
player = game_data.player
|
|
979
|
-
assert player is not None
|
|
980
|
-
wall_group = game_data.groups.wall_group
|
|
981
|
-
offsets = [
|
|
982
|
-
(BUDDY_RADIUS * 3, 0),
|
|
983
|
-
(-BUDDY_RADIUS * 3, 0),
|
|
984
|
-
(0, BUDDY_RADIUS * 3),
|
|
985
|
-
(0, -BUDDY_RADIUS * 3),
|
|
986
|
-
(0, 0),
|
|
987
|
-
]
|
|
988
|
-
for _ in range(count):
|
|
989
|
-
spawn_pos = (int(player.x), int(player.y))
|
|
990
|
-
for dx, dy in offsets:
|
|
991
|
-
candidate = Survivor(player.x + dx, player.y + dy, is_buddy=True)
|
|
992
|
-
if not spritecollideany_walls(candidate, wall_group):
|
|
993
|
-
spawn_pos = (candidate.x, candidate.y)
|
|
994
|
-
break
|
|
995
|
-
|
|
996
|
-
buddy = Survivor(*spawn_pos, is_buddy=True)
|
|
997
|
-
buddy.following = True
|
|
998
|
-
game_data.groups.all_sprites.add(buddy, layer=2)
|
|
999
|
-
game_data.groups.survivor_group.add(buddy)
|
|
1000
|
-
game_data.state.buddy_onboard = 0
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
def get_shrunk_sprite(
|
|
1004
|
-
sprite_obj: pygame.sprite.Sprite, scale_x: float, *, scale_y: float | None = None
|
|
1005
|
-
) -> pygame.sprite.Sprite:
|
|
1006
|
-
if scale_y is None:
|
|
1007
|
-
scale_y = scale_x
|
|
1008
|
-
|
|
1009
|
-
original_rect = sprite_obj.rect
|
|
1010
|
-
shrunk_width = int(original_rect.width * scale_x)
|
|
1011
|
-
shrunk_height = int(original_rect.height * scale_y)
|
|
1012
|
-
|
|
1013
|
-
shrunk_width = max(1, shrunk_width)
|
|
1014
|
-
shrunk_height = max(1, shrunk_height)
|
|
1015
|
-
|
|
1016
|
-
rect = pygame.Rect(0, 0, shrunk_width, shrunk_height)
|
|
1017
|
-
rect.center = original_rect.center
|
|
1018
|
-
|
|
1019
|
-
new_sprite = pygame.sprite.Sprite()
|
|
1020
|
-
new_sprite.rect = rect
|
|
1021
|
-
|
|
1022
|
-
return new_sprite
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
def update_footprints(game_data: GameData, config: dict[str, Any]) -> None:
|
|
1026
|
-
"""Record player steps and clean up old footprints."""
|
|
1027
|
-
state = game_data.state
|
|
1028
|
-
player = game_data.player
|
|
1029
|
-
assert player is not None
|
|
1030
|
-
footprints_enabled = config.get("footprints", {}).get("enabled", True)
|
|
1031
|
-
if not footprints_enabled:
|
|
1032
|
-
state.footprints = []
|
|
1033
|
-
state.last_footprint_pos = None
|
|
1034
|
-
return
|
|
1035
|
-
|
|
1036
|
-
now = pygame.time.get_ticks()
|
|
1037
|
-
|
|
1038
|
-
footprints = state.footprints
|
|
1039
|
-
if not player.in_car:
|
|
1040
|
-
last_pos = state.last_footprint_pos
|
|
1041
|
-
dist = (
|
|
1042
|
-
math.hypot(player.x - last_pos[0], player.y - last_pos[1])
|
|
1043
|
-
if last_pos
|
|
1044
|
-
else None
|
|
1045
|
-
)
|
|
1046
|
-
if last_pos is None or (dist is not None and dist >= FOOTPRINT_STEP_DISTANCE):
|
|
1047
|
-
footprints.append({"pos": (player.x, player.y), "time": now})
|
|
1048
|
-
state.last_footprint_pos = (player.x, player.y)
|
|
1049
|
-
|
|
1050
|
-
if len(footprints) > FOOTPRINT_MAX:
|
|
1051
|
-
footprints = footprints[-FOOTPRINT_MAX:]
|
|
1052
|
-
|
|
1053
|
-
state.footprints = footprints
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
1057
|
-
"""Initialize and return the base game state objects."""
|
|
1058
|
-
starts_with_fuel = not stage.requires_fuel
|
|
1059
|
-
if stage.survival_stage:
|
|
1060
|
-
starts_with_fuel = False
|
|
1061
|
-
starts_with_flashlight = False
|
|
1062
|
-
initial_flashlights = 1 if starts_with_flashlight else 0
|
|
1063
|
-
initial_palette_key = ambient_palette_key_for_flashlights(initial_flashlights)
|
|
1064
|
-
game_state = ProgressState(
|
|
1065
|
-
game_over=False,
|
|
1066
|
-
game_won=False,
|
|
1067
|
-
game_over_message=None,
|
|
1068
|
-
game_over_at=None,
|
|
1069
|
-
scaled_overview=None,
|
|
1070
|
-
overview_created=False,
|
|
1071
|
-
footprints=[],
|
|
1072
|
-
last_footprint_pos=None,
|
|
1073
|
-
elapsed_play_ms=0,
|
|
1074
|
-
has_fuel=starts_with_fuel,
|
|
1075
|
-
flashlight_count=initial_flashlights,
|
|
1076
|
-
ambient_palette_key=initial_palette_key,
|
|
1077
|
-
hint_expires_at=0,
|
|
1078
|
-
hint_target_type=None,
|
|
1079
|
-
fuel_message_until=0,
|
|
1080
|
-
buddy_rescued=0,
|
|
1081
|
-
buddy_onboard=0,
|
|
1082
|
-
survivors_onboard=0,
|
|
1083
|
-
survivors_rescued=0,
|
|
1084
|
-
survivor_messages=[],
|
|
1085
|
-
survivor_capacity=SURVIVOR_MAX_SAFE_PASSENGERS,
|
|
1086
|
-
seed=None,
|
|
1087
|
-
survival_elapsed_ms=0,
|
|
1088
|
-
survival_goal_ms=max(0, stage.survival_goal_ms),
|
|
1089
|
-
dawn_ready=False,
|
|
1090
|
-
dawn_prompt_at=None,
|
|
1091
|
-
time_accel_active=False,
|
|
1092
|
-
last_zombie_spawn_time=0,
|
|
1093
|
-
dawn_carbonized=False,
|
|
1094
|
-
debug_mode=False,
|
|
1095
|
-
)
|
|
1096
|
-
|
|
1097
|
-
# Create sprite groups
|
|
1098
|
-
all_sprites = pygame.sprite.LayeredUpdates()
|
|
1099
|
-
wall_group = pygame.sprite.Group()
|
|
1100
|
-
zombie_group = pygame.sprite.Group()
|
|
1101
|
-
survivor_group = pygame.sprite.Group()
|
|
1102
|
-
|
|
1103
|
-
# Create camera
|
|
1104
|
-
camera = Camera(LEVEL_WIDTH, LEVEL_HEIGHT)
|
|
1105
|
-
|
|
1106
|
-
# Define level areas (will be filled by blueprint generation)
|
|
1107
|
-
outer_rect = 0, 0, LEVEL_WIDTH, LEVEL_HEIGHT
|
|
1108
|
-
inner_rect = outer_rect
|
|
1109
|
-
|
|
1110
|
-
return GameData(
|
|
1111
|
-
state=game_state,
|
|
1112
|
-
groups=Groups(
|
|
1113
|
-
all_sprites=all_sprites,
|
|
1114
|
-
wall_group=wall_group,
|
|
1115
|
-
zombie_group=zombie_group,
|
|
1116
|
-
survivor_group=survivor_group,
|
|
1117
|
-
),
|
|
1118
|
-
camera=camera,
|
|
1119
|
-
areas=Areas(
|
|
1120
|
-
outer_rect=outer_rect,
|
|
1121
|
-
inner_rect=inner_rect,
|
|
1122
|
-
outside_rects=[],
|
|
1123
|
-
walkable_cells=[],
|
|
1124
|
-
outer_wall_cells=set(),
|
|
1125
|
-
),
|
|
1126
|
-
fog={
|
|
1127
|
-
"hatch_patterns": {},
|
|
1128
|
-
"overlays": {},
|
|
1129
|
-
},
|
|
1130
|
-
stage=stage,
|
|
1131
|
-
fuel=None,
|
|
1132
|
-
flashlights=[],
|
|
1133
|
-
)
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
def setup_player_and_cars(
|
|
1137
|
-
game_data: GameData,
|
|
1138
|
-
layout_data: Mapping[str, list[pygame.Rect]],
|
|
1139
|
-
*,
|
|
1140
|
-
car_count: int = 1,
|
|
1141
|
-
) -> tuple[Player, list[Car]]:
|
|
1142
|
-
"""Create the player plus one or more parked cars using blueprint candidates."""
|
|
1143
|
-
all_sprites = game_data.groups.all_sprites
|
|
1144
|
-
walkable_cells: list[pygame.Rect] = layout_data["walkable_cells"]
|
|
1145
|
-
|
|
1146
|
-
def pick_center(cells: list[pygame.Rect]) -> tuple[int, int]:
|
|
1147
|
-
return (
|
|
1148
|
-
RNG.choice(cells).center
|
|
1149
|
-
if cells
|
|
1150
|
-
else (LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2)
|
|
1151
|
-
)
|
|
1152
|
-
|
|
1153
|
-
player_pos = pick_center(layout_data["player_cells"] or walkable_cells)
|
|
1154
|
-
player = Player(*player_pos)
|
|
1155
|
-
|
|
1156
|
-
car_candidates = list(layout_data["car_cells"] or walkable_cells)
|
|
1157
|
-
waiting_cars: list[Car] = []
|
|
1158
|
-
car_appearance = car_appearance_for_stage(game_data.stage)
|
|
1159
|
-
|
|
1160
|
-
def _pick_car_position() -> tuple[int, int]:
|
|
1161
|
-
"""Favor distant cells for the first car, otherwise fall back to random picks."""
|
|
1162
|
-
if not car_candidates:
|
|
1163
|
-
return (player_pos[0] + 200, player_pos[1])
|
|
1164
|
-
RNG.shuffle(car_candidates)
|
|
1165
|
-
for candidate in car_candidates:
|
|
1166
|
-
if (
|
|
1167
|
-
math.hypot(
|
|
1168
|
-
candidate.centerx - player_pos[0],
|
|
1169
|
-
candidate.centery - player_pos[1],
|
|
1170
|
-
)
|
|
1171
|
-
>= 400
|
|
1172
|
-
):
|
|
1173
|
-
car_candidates.remove(candidate)
|
|
1174
|
-
return candidate.center
|
|
1175
|
-
# No far-enough cells found; pick the first available
|
|
1176
|
-
choice = car_candidates.pop()
|
|
1177
|
-
return choice.center
|
|
1178
|
-
|
|
1179
|
-
for idx in range(max(1, car_count)):
|
|
1180
|
-
car_pos = _pick_car_position()
|
|
1181
|
-
car = Car(*car_pos, appearance=car_appearance)
|
|
1182
|
-
waiting_cars.append(car)
|
|
1183
|
-
all_sprites.add(car, layer=1)
|
|
1184
|
-
if not car_candidates:
|
|
1185
|
-
break
|
|
1186
|
-
|
|
1187
|
-
all_sprites.add(player, layer=2)
|
|
1188
|
-
return player, waiting_cars
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
def spawn_initial_zombies(
|
|
1192
|
-
game_data: GameData,
|
|
1193
|
-
player: Player,
|
|
1194
|
-
layout_data: Mapping[str, list[pygame.Rect]],
|
|
1195
|
-
config: dict[str, Any],
|
|
1196
|
-
) -> None:
|
|
1197
|
-
"""Spawn initial zombies using blueprint candidate cells."""
|
|
1198
|
-
wall_group = game_data.groups.wall_group
|
|
1199
|
-
zombie_group = game_data.groups.zombie_group
|
|
1200
|
-
all_sprites = game_data.groups.all_sprites
|
|
1201
|
-
|
|
1202
|
-
spawn_cells = layout_data["walkable_cells"]
|
|
1203
|
-
if not spawn_cells:
|
|
1204
|
-
return
|
|
1205
|
-
|
|
1206
|
-
spawn_rate = max(0.0, getattr(game_data.stage, "initial_interior_spawn_rate", 0.0))
|
|
1207
|
-
positions = scatter_positions_on_walkable(spawn_cells, spawn_rate)
|
|
1208
|
-
if not positions:
|
|
1209
|
-
positions = scatter_positions_on_walkable(spawn_cells, spawn_rate * 1.5)
|
|
1210
|
-
|
|
1211
|
-
for pos in positions:
|
|
1212
|
-
if (
|
|
1213
|
-
math.hypot(pos[0] - player.x, pos[1] - player.y)
|
|
1214
|
-
< ZOMBIE_SPAWN_PLAYER_BUFFER
|
|
1215
|
-
):
|
|
1216
|
-
continue
|
|
1217
|
-
tentative = create_zombie(
|
|
1218
|
-
config,
|
|
1219
|
-
start_pos=pos,
|
|
1220
|
-
stage=game_data.stage,
|
|
1221
|
-
outer_wall_cells=game_data.areas.outer_wall_cells,
|
|
1222
|
-
)
|
|
1223
|
-
if spritecollideany_walls(tentative, wall_group):
|
|
1224
|
-
continue
|
|
1225
|
-
zombie_group.add(tentative)
|
|
1226
|
-
all_sprites.add(tentative, layer=1)
|
|
1227
|
-
|
|
1228
|
-
interval = max(1, getattr(game_data.stage, "spawn_interval_ms", ZOMBIE_SPAWN_DELAY_MS))
|
|
1229
|
-
game_data.state.last_zombie_spawn_time = pygame.time.get_ticks() - interval
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
def spawn_nearby_zombie(
|
|
1233
|
-
game_data: GameData,
|
|
1234
|
-
config: dict[str, Any],
|
|
1235
|
-
) -> Zombie | None:
|
|
1236
|
-
"""Spawn a zombie just outside of the current camera frustum."""
|
|
1237
|
-
player = game_data.player
|
|
1238
|
-
if not player:
|
|
1239
|
-
return None
|
|
1240
|
-
zombie_group = game_data.groups.zombie_group
|
|
1241
|
-
if len(zombie_group) >= MAX_ZOMBIES:
|
|
1242
|
-
return None
|
|
1243
|
-
camera = game_data.camera
|
|
1244
|
-
view_rect = pygame.Rect(
|
|
1245
|
-
-camera.camera.x,
|
|
1246
|
-
-camera.camera.y,
|
|
1247
|
-
SCREEN_WIDTH,
|
|
1248
|
-
SCREEN_HEIGHT,
|
|
1249
|
-
)
|
|
1250
|
-
view_rect.inflate_ip(
|
|
1251
|
-
SURVIVAL_NEAR_SPAWN_CAMERA_MARGIN * 2,
|
|
1252
|
-
SURVIVAL_NEAR_SPAWN_CAMERA_MARGIN * 2,
|
|
1253
|
-
)
|
|
1254
|
-
wall_group = game_data.groups.wall_group
|
|
1255
|
-
all_sprites = game_data.groups.all_sprites
|
|
1256
|
-
for _ in range(18):
|
|
1257
|
-
angle = RNG.uniform(0, math.tau)
|
|
1258
|
-
distance = RNG.uniform(
|
|
1259
|
-
SURVIVAL_NEAR_SPAWN_MIN_DISTANCE,
|
|
1260
|
-
SURVIVAL_NEAR_SPAWN_MAX_DISTANCE,
|
|
1261
|
-
)
|
|
1262
|
-
spawn_x = player.x + math.cos(angle) * distance
|
|
1263
|
-
spawn_y = player.y + math.sin(angle) * distance
|
|
1264
|
-
candidate = (
|
|
1265
|
-
int(max(0, min(LEVEL_WIDTH, spawn_x))),
|
|
1266
|
-
int(max(0, min(LEVEL_HEIGHT, spawn_y))),
|
|
1267
|
-
)
|
|
1268
|
-
if view_rect.collidepoint(candidate):
|
|
1269
|
-
continue
|
|
1270
|
-
new_zombie = create_zombie(
|
|
1271
|
-
config,
|
|
1272
|
-
start_pos=candidate,
|
|
1273
|
-
stage=game_data.stage,
|
|
1274
|
-
outer_wall_cells=game_data.areas.outer_wall_cells,
|
|
1275
|
-
)
|
|
1276
|
-
if spritecollideany_walls(new_zombie, wall_group):
|
|
1277
|
-
continue
|
|
1278
|
-
zombie_group.add(new_zombie)
|
|
1279
|
-
all_sprites.add(new_zombie, layer=1)
|
|
1280
|
-
return new_zombie
|
|
1281
|
-
return None
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
def spawn_exterior_zombie(
|
|
1285
|
-
game_data: GameData,
|
|
1286
|
-
config: dict[str, Any],
|
|
1287
|
-
) -> Zombie | None:
|
|
1288
|
-
"""Spawn a zombie using the standard exterior hint logic."""
|
|
1289
|
-
player = game_data.player
|
|
1290
|
-
if not player:
|
|
1291
|
-
return None
|
|
1292
|
-
zombie_group = game_data.groups.zombie_group
|
|
1293
|
-
all_sprites = game_data.groups.all_sprites
|
|
1294
|
-
new_zombie = create_zombie(
|
|
1295
|
-
config,
|
|
1296
|
-
hint_pos=(player.x, player.y),
|
|
1297
|
-
stage=game_data.stage,
|
|
1298
|
-
outer_wall_cells=game_data.areas.outer_wall_cells,
|
|
1299
|
-
)
|
|
1300
|
-
zombie_group.add(new_zombie)
|
|
1301
|
-
all_sprites.add(new_zombie, layer=1)
|
|
1302
|
-
return new_zombie
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
def spawn_weighted_zombie(
|
|
1306
|
-
game_data: GameData,
|
|
1307
|
-
config: dict[str, Any],
|
|
1308
|
-
) -> bool:
|
|
1309
|
-
"""Spawn a zombie according to the stage's interior/exterior mix."""
|
|
1310
|
-
stage = game_data.stage
|
|
1311
|
-
def _spawn(choice: str) -> bool:
|
|
1312
|
-
if choice == "interior":
|
|
1313
|
-
return spawn_nearby_zombie(game_data, config) is not None
|
|
1314
|
-
return spawn_exterior_zombie(game_data, config) is not None
|
|
1315
|
-
|
|
1316
|
-
interior_weight = max(0.0, stage.interior_spawn_weight)
|
|
1317
|
-
exterior_weight = max(0.0, stage.exterior_spawn_weight)
|
|
1318
|
-
total_weight = interior_weight + exterior_weight
|
|
1319
|
-
if total_weight <= 0:
|
|
1320
|
-
# Fall back to exterior spawns if weights are unset or invalid.
|
|
1321
|
-
return _spawn("exterior")
|
|
1322
|
-
|
|
1323
|
-
pick = RNG.uniform(0, total_weight)
|
|
1324
|
-
if pick <= interior_weight:
|
|
1325
|
-
if _spawn("interior"):
|
|
1326
|
-
return True
|
|
1327
|
-
return _spawn("exterior")
|
|
1328
|
-
if _spawn("exterior"):
|
|
1329
|
-
return True
|
|
1330
|
-
return _spawn("interior")
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
def carbonize_outdoor_zombies(game_data: GameData) -> None:
|
|
1334
|
-
"""Petrify zombies that have already broken through to the exterior."""
|
|
1335
|
-
outside_rects = game_data.areas.outside_rects or []
|
|
1336
|
-
if not outside_rects:
|
|
1337
|
-
return
|
|
1338
|
-
group = game_data.groups.zombie_group
|
|
1339
|
-
if not group:
|
|
1340
|
-
return
|
|
1341
|
-
for zombie in list(group):
|
|
1342
|
-
alive = getattr(zombie, "alive", lambda: False)
|
|
1343
|
-
if not alive():
|
|
1344
|
-
continue
|
|
1345
|
-
center = zombie.rect.center
|
|
1346
|
-
if any(rect_obj.collidepoint(center) for rect_obj in outside_rects):
|
|
1347
|
-
carbonize = getattr(zombie, "carbonize", None)
|
|
1348
|
-
if carbonize:
|
|
1349
|
-
carbonize()
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
def update_survival_timer(game_data: GameData, dt_ms: int) -> None:
|
|
1353
|
-
"""Advance the survival countdown and trigger dawn handoff."""
|
|
1354
|
-
stage = game_data.stage
|
|
1355
|
-
state = game_data.state
|
|
1356
|
-
if not stage.survival_stage:
|
|
1357
|
-
return
|
|
1358
|
-
if state.survival_goal_ms <= 0 or dt_ms <= 0:
|
|
1359
|
-
return
|
|
1360
|
-
state.survival_elapsed_ms = min(
|
|
1361
|
-
state.survival_goal_ms,
|
|
1362
|
-
state.survival_elapsed_ms + dt_ms,
|
|
1363
|
-
)
|
|
1364
|
-
if not state.dawn_ready and state.survival_elapsed_ms >= state.survival_goal_ms:
|
|
1365
|
-
state.dawn_ready = True
|
|
1366
|
-
state.dawn_prompt_at = pygame.time.get_ticks()
|
|
1367
|
-
set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=True)
|
|
1368
|
-
if state.dawn_ready:
|
|
1369
|
-
carbonize_outdoor_zombies(game_data)
|
|
1370
|
-
state.dawn_carbonized = True
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
def process_player_input(
|
|
1374
|
-
keys: Sequence[bool], player: Player, car: Car | None
|
|
1375
|
-
) -> tuple[float, float, float, float]:
|
|
1376
|
-
"""Process keyboard input and return movement deltas."""
|
|
1377
|
-
dx_input, dy_input = 0, 0
|
|
1378
|
-
if keys[pygame.K_w] or keys[pygame.K_UP]:
|
|
1379
|
-
dy_input -= 1
|
|
1380
|
-
if keys[pygame.K_s] or keys[pygame.K_DOWN]:
|
|
1381
|
-
dy_input += 1
|
|
1382
|
-
if keys[pygame.K_a] or keys[pygame.K_LEFT]:
|
|
1383
|
-
dx_input -= 1
|
|
1384
|
-
if keys[pygame.K_d] or keys[pygame.K_RIGHT]:
|
|
1385
|
-
dx_input += 1
|
|
1386
|
-
|
|
1387
|
-
player_dx, player_dy, car_dx, car_dy = 0, 0, 0, 0
|
|
1388
|
-
|
|
1389
|
-
if player.in_car and car and car.alive():
|
|
1390
|
-
target_speed = getattr(car, "speed", CAR_SPEED)
|
|
1391
|
-
move_len = math.hypot(dx_input, dy_input)
|
|
1392
|
-
if move_len > 0:
|
|
1393
|
-
car_dx, car_dy = (
|
|
1394
|
-
(dx_input / move_len) * target_speed,
|
|
1395
|
-
(dy_input / move_len) * target_speed,
|
|
1396
|
-
)
|
|
1397
|
-
elif not player.in_car:
|
|
1398
|
-
target_speed = PLAYER_SPEED
|
|
1399
|
-
move_len = math.hypot(dx_input, dy_input)
|
|
1400
|
-
if move_len > 0:
|
|
1401
|
-
player_dx, player_dy = (
|
|
1402
|
-
(dx_input / move_len) * target_speed,
|
|
1403
|
-
(dy_input / move_len) * target_speed,
|
|
1404
|
-
)
|
|
1405
|
-
|
|
1406
|
-
return player_dx, player_dy, car_dx, car_dy
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
def update_entities(
|
|
1410
|
-
game_data: GameData,
|
|
1411
|
-
player_dx: float,
|
|
1412
|
-
player_dy: float,
|
|
1413
|
-
car_dx: float,
|
|
1414
|
-
car_dy: float,
|
|
1415
|
-
config: dict[str, Any],
|
|
1416
|
-
wall_index: WallIndex | None = None,
|
|
1417
|
-
) -> None:
|
|
1418
|
-
"""Update positions and states of game entities."""
|
|
1419
|
-
player = game_data.player
|
|
1420
|
-
assert player is not None
|
|
1421
|
-
car = game_data.car
|
|
1422
|
-
wall_group = game_data.groups.wall_group
|
|
1423
|
-
all_sprites = game_data.groups.all_sprites
|
|
1424
|
-
zombie_group = game_data.groups.zombie_group
|
|
1425
|
-
survivor_group = game_data.groups.survivor_group
|
|
1426
|
-
camera = game_data.camera
|
|
1427
|
-
stage = game_data.stage
|
|
1428
|
-
active_car = car if car and car.alive() else None
|
|
1429
|
-
|
|
1430
|
-
all_walls = list(wall_group) if wall_index is None else None
|
|
1431
|
-
|
|
1432
|
-
def walls_near(center: tuple[float, float], radius: float) -> list[Wall]:
|
|
1433
|
-
if wall_index is None:
|
|
1434
|
-
return all_walls or []
|
|
1435
|
-
return walls_for_radius(wall_index, center, radius)
|
|
1436
|
-
|
|
1437
|
-
# Update player/car movement
|
|
1438
|
-
if player.in_car and active_car:
|
|
1439
|
-
car_walls = walls_near((active_car.x, active_car.y), 150.0)
|
|
1440
|
-
active_car.move(car_dx, car_dy, car_walls)
|
|
1441
|
-
player.rect.center = active_car.rect.center
|
|
1442
|
-
player.x, player.y = active_car.x, active_car.y
|
|
1443
|
-
elif not player.in_car:
|
|
1444
|
-
# Ensure player is in all_sprites if not in car
|
|
1445
|
-
if player not in all_sprites:
|
|
1446
|
-
all_sprites.add(player, layer=2)
|
|
1447
|
-
player.move(player_dx, player_dy, wall_group, wall_index=wall_index)
|
|
1448
|
-
else:
|
|
1449
|
-
# Player flagged as in-car but car is gone; drop them back to foot control
|
|
1450
|
-
player.in_car = False
|
|
1451
|
-
|
|
1452
|
-
# Update camera
|
|
1453
|
-
target_for_camera = active_car if player.in_car and active_car else player
|
|
1454
|
-
camera.update(target_for_camera)
|
|
1455
|
-
|
|
1456
|
-
update_survivors(game_data, wall_index=wall_index)
|
|
1457
|
-
|
|
1458
|
-
# Spawn new zombies if needed
|
|
1459
|
-
current_time = pygame.time.get_ticks()
|
|
1460
|
-
spawn_interval = max(1, getattr(stage, "spawn_interval_ms", ZOMBIE_SPAWN_DELAY_MS))
|
|
1461
|
-
spawn_blocked = stage.survival_stage and game_data.state.dawn_ready
|
|
1462
|
-
if (
|
|
1463
|
-
len(zombie_group) < MAX_ZOMBIES
|
|
1464
|
-
and not spawn_blocked
|
|
1465
|
-
and current_time - game_data.state.last_zombie_spawn_time > spawn_interval
|
|
1466
|
-
):
|
|
1467
|
-
if spawn_weighted_zombie(game_data, config):
|
|
1468
|
-
game_data.state.last_zombie_spawn_time = current_time
|
|
1469
|
-
|
|
1470
|
-
# Update zombies
|
|
1471
|
-
target_center = (
|
|
1472
|
-
active_car.rect.center if player.in_car and active_car else player.rect.center
|
|
1473
|
-
)
|
|
1474
|
-
buddies = [
|
|
1475
|
-
survivor
|
|
1476
|
-
for survivor in survivor_group
|
|
1477
|
-
if survivor.alive() and survivor.is_buddy and not survivor.rescued
|
|
1478
|
-
]
|
|
1479
|
-
buddies_on_screen = [
|
|
1480
|
-
buddy for buddy in buddies if rect_visible_on_screen(camera, buddy.rect)
|
|
1481
|
-
]
|
|
1482
|
-
|
|
1483
|
-
survivors_on_screen: list[Survivor] = []
|
|
1484
|
-
if stage.rescue_stage:
|
|
1485
|
-
for survivor in survivor_group:
|
|
1486
|
-
if survivor.alive():
|
|
1487
|
-
if rect_visible_on_screen(camera, survivor.rect):
|
|
1488
|
-
survivors_on_screen.append(survivor)
|
|
1489
|
-
|
|
1490
|
-
zombies_sorted: list[Zombie] = sorted(list(zombie_group), key=lambda z: z.x)
|
|
1491
|
-
|
|
1492
|
-
def _nearby_zombies(index: int) -> list[Zombie]:
|
|
1493
|
-
center = zombies_sorted[index]
|
|
1494
|
-
neighbors: list[Zombie] = []
|
|
1495
|
-
search_radius = ZOMBIE_SEPARATION_DISTANCE + PLAYER_SPEED
|
|
1496
|
-
for left in range(index - 1, -1, -1):
|
|
1497
|
-
other = zombies_sorted[left]
|
|
1498
|
-
if center.x - other.x > search_radius:
|
|
1499
|
-
break
|
|
1500
|
-
if other.alive():
|
|
1501
|
-
neighbors.append(other)
|
|
1502
|
-
for right in range(index + 1, len(zombies_sorted)):
|
|
1503
|
-
other = zombies_sorted[right]
|
|
1504
|
-
if other.x - center.x > search_radius:
|
|
1505
|
-
break
|
|
1506
|
-
if other.alive():
|
|
1507
|
-
neighbors.append(other)
|
|
1508
|
-
return neighbors
|
|
1509
|
-
|
|
1510
|
-
for idx, zombie in enumerate(zombies_sorted):
|
|
1511
|
-
target = target_center
|
|
1512
|
-
if buddies_on_screen:
|
|
1513
|
-
dist_to_target = math.hypot(
|
|
1514
|
-
target_center[0] - zombie.x, target_center[1] - zombie.y
|
|
1515
|
-
)
|
|
1516
|
-
nearest_buddy = min(
|
|
1517
|
-
buddies_on_screen,
|
|
1518
|
-
key=lambda buddy: math.hypot(
|
|
1519
|
-
buddy.rect.centerx - zombie.x, buddy.rect.centery - zombie.y
|
|
1520
|
-
),
|
|
1521
|
-
)
|
|
1522
|
-
dist_to_buddy = math.hypot(
|
|
1523
|
-
nearest_buddy.rect.centerx - zombie.x,
|
|
1524
|
-
nearest_buddy.rect.centery - zombie.y,
|
|
1525
|
-
)
|
|
1526
|
-
if dist_to_buddy < dist_to_target:
|
|
1527
|
-
target = nearest_buddy.rect.center
|
|
1528
|
-
|
|
1529
|
-
if stage.rescue_stage:
|
|
1530
|
-
zombie_on_screen = rect_visible_on_screen(camera, zombie.rect)
|
|
1531
|
-
if zombie_on_screen:
|
|
1532
|
-
candidate_positions: list[tuple[int, int]] = []
|
|
1533
|
-
for survivor in survivors_on_screen:
|
|
1534
|
-
candidate_positions.append(survivor.rect.center)
|
|
1535
|
-
for buddy in buddies_on_screen:
|
|
1536
|
-
candidate_positions.append(buddy.rect.center)
|
|
1537
|
-
candidate_positions.append(player.rect.center)
|
|
1538
|
-
if candidate_positions:
|
|
1539
|
-
target = min(
|
|
1540
|
-
candidate_positions,
|
|
1541
|
-
key=lambda pos: math.hypot(
|
|
1542
|
-
pos[0] - zombie.x, pos[1] - zombie.y
|
|
1543
|
-
),
|
|
1544
|
-
)
|
|
1545
|
-
nearby_candidates = _nearby_zombies(idx)
|
|
1546
|
-
zombie_search_radius = ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE + zombie.radius + 120
|
|
1547
|
-
nearby_walls = walls_near((zombie.x, zombie.y), zombie_search_radius)
|
|
1548
|
-
zombie.update(
|
|
1549
|
-
target,
|
|
1550
|
-
nearby_walls,
|
|
1551
|
-
nearby_candidates,
|
|
1552
|
-
footprints=game_data.state.footprints,
|
|
1553
|
-
)
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
def check_interactions(
|
|
1557
|
-
game_data: GameData, config: dict[str, Any]
|
|
1558
|
-
) -> pygame.sprite.Sprite | None:
|
|
1559
|
-
"""Check and handle interactions between entities."""
|
|
1560
|
-
player = game_data.player
|
|
1561
|
-
assert player is not None
|
|
1562
|
-
car = game_data.car
|
|
1563
|
-
zombie_group = game_data.groups.zombie_group
|
|
1564
|
-
all_sprites = game_data.groups.all_sprites
|
|
1565
|
-
survivor_group = game_data.groups.survivor_group
|
|
1566
|
-
state = game_data.state
|
|
1567
|
-
walkable_cells = game_data.areas.walkable_cells
|
|
1568
|
-
outside_rects = game_data.areas.outside_rects
|
|
1569
|
-
fuel = game_data.fuel
|
|
1570
|
-
flashlights = game_data.flashlights or []
|
|
1571
|
-
camera = game_data.camera
|
|
1572
|
-
stage = game_data.stage
|
|
1573
|
-
maintain_waiting_car_supply(game_data)
|
|
1574
|
-
active_car = car if car and car.alive() else None
|
|
1575
|
-
waiting_cars = game_data.waiting_cars
|
|
1576
|
-
shrunk_car = get_shrunk_sprite(active_car, 0.8) if active_car else None
|
|
1577
|
-
|
|
1578
|
-
car_interaction_radius = interaction_radius(CAR_WIDTH, CAR_HEIGHT)
|
|
1579
|
-
fuel_interaction_radius = interaction_radius(FUEL_CAN_WIDTH, FUEL_CAN_HEIGHT)
|
|
1580
|
-
flashlight_interaction_radius = interaction_radius(
|
|
1581
|
-
FLASHLIGHT_WIDTH, FLASHLIGHT_HEIGHT
|
|
1582
|
-
)
|
|
1583
|
-
|
|
1584
|
-
def player_near_point(point: tuple[float, float], radius: float) -> bool:
|
|
1585
|
-
return math.hypot(point[0] - player.x, point[1] - player.y) <= radius
|
|
1586
|
-
|
|
1587
|
-
def player_near_sprite(
|
|
1588
|
-
sprite_obj: pygame.sprite.Sprite | None, radius: float
|
|
1589
|
-
) -> bool:
|
|
1590
|
-
return bool(
|
|
1591
|
-
sprite_obj
|
|
1592
|
-
and sprite_obj.alive()
|
|
1593
|
-
and player_near_point(sprite_obj.rect.center, radius)
|
|
1594
|
-
)
|
|
1595
|
-
|
|
1596
|
-
def player_near_car(car_obj: Car | None) -> bool:
|
|
1597
|
-
return player_near_sprite(car_obj, car_interaction_radius)
|
|
1598
|
-
|
|
1599
|
-
# Fuel pickup
|
|
1600
|
-
if fuel and fuel.alive() and not state.has_fuel and not player.in_car:
|
|
1601
|
-
if player_near_point(fuel.rect.center, fuel_interaction_radius):
|
|
1602
|
-
state.has_fuel = True
|
|
1603
|
-
state.fuel_message_until = 0
|
|
1604
|
-
state.hint_expires_at = 0
|
|
1605
|
-
state.hint_target_type = None
|
|
1606
|
-
fuel.kill()
|
|
1607
|
-
game_data.fuel = None
|
|
1608
|
-
print("Fuel acquired!")
|
|
1609
|
-
|
|
1610
|
-
# Flashlight pickup
|
|
1611
|
-
if not player.in_car:
|
|
1612
|
-
for flashlight in list(flashlights):
|
|
1613
|
-
if not flashlight.alive():
|
|
1614
|
-
continue
|
|
1615
|
-
if player_near_point(
|
|
1616
|
-
flashlight.rect.center, flashlight_interaction_radius
|
|
1617
|
-
):
|
|
1618
|
-
state.flashlight_count += 1
|
|
1619
|
-
state.hint_expires_at = 0
|
|
1620
|
-
state.hint_target_type = None
|
|
1621
|
-
flashlight.kill()
|
|
1622
|
-
try:
|
|
1623
|
-
flashlights.remove(flashlight)
|
|
1624
|
-
except ValueError:
|
|
1625
|
-
pass
|
|
1626
|
-
print("Flashlight acquired!")
|
|
1627
|
-
break
|
|
1628
|
-
|
|
1629
|
-
sync_ambient_palette_with_flashlights(game_data)
|
|
1630
|
-
|
|
1631
|
-
buddies = [
|
|
1632
|
-
survivor
|
|
1633
|
-
for survivor in survivor_group
|
|
1634
|
-
if survivor.alive() and survivor.is_buddy and not survivor.rescued
|
|
1635
|
-
]
|
|
1636
|
-
|
|
1637
|
-
# Buddy interactions (Stage 3)
|
|
1638
|
-
if stage.buddy_required_count > 0 and buddies:
|
|
1639
|
-
for buddy in list(buddies):
|
|
1640
|
-
if not buddy.alive():
|
|
1641
|
-
continue
|
|
1642
|
-
buddy_on_screen = rect_visible_on_screen(camera, buddy.rect)
|
|
1643
|
-
if not player.in_car:
|
|
1644
|
-
dist_to_player = math.hypot(player.x - buddy.x, player.y - buddy.y)
|
|
1645
|
-
if dist_to_player <= SURVIVOR_APPROACH_RADIUS:
|
|
1646
|
-
buddy.set_following()
|
|
1647
|
-
elif player.in_car and active_car and shrunk_car:
|
|
1648
|
-
g = pygame.sprite.Group()
|
|
1649
|
-
g.add(buddy)
|
|
1650
|
-
if pygame.sprite.spritecollide(
|
|
1651
|
-
shrunk_car, g, False, pygame.sprite.collide_circle
|
|
1652
|
-
):
|
|
1653
|
-
prospective_passengers = state.survivors_onboard + 1
|
|
1654
|
-
capacity_limit = state.survivor_capacity
|
|
1655
|
-
if prospective_passengers > capacity_limit:
|
|
1656
|
-
overload_damage = max(
|
|
1657
|
-
1,
|
|
1658
|
-
int(active_car.max_health * SURVIVOR_OVERLOAD_DAMAGE_RATIO),
|
|
1659
|
-
)
|
|
1660
|
-
add_survivor_message(game_data, tr("survivors.too_many_aboard"))
|
|
1661
|
-
active_car.take_damage(overload_damage)
|
|
1662
|
-
state.buddy_onboard += 1
|
|
1663
|
-
buddy.kill()
|
|
1664
|
-
continue
|
|
1665
|
-
|
|
1666
|
-
if buddy.alive() and pygame.sprite.spritecollide(
|
|
1667
|
-
buddy, zombie_group, False, pygame.sprite.collide_circle
|
|
1668
|
-
):
|
|
1669
|
-
if buddy_on_screen:
|
|
1670
|
-
state.game_over_message = tr("game_over.scream")
|
|
1671
|
-
state.game_over = True
|
|
1672
|
-
state.game_over_at = state.game_over_at or pygame.time.get_ticks()
|
|
1673
|
-
else:
|
|
1674
|
-
if walkable_cells:
|
|
1675
|
-
new_cell = RNG.choice(walkable_cells)
|
|
1676
|
-
buddy.teleport(new_cell.center)
|
|
1677
|
-
else:
|
|
1678
|
-
buddy.teleport((LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2))
|
|
1679
|
-
buddy.following = False
|
|
1680
|
-
|
|
1681
|
-
# Player entering an active car already under control
|
|
1682
|
-
if (
|
|
1683
|
-
not player.in_car
|
|
1684
|
-
and player_near_car(active_car)
|
|
1685
|
-
and active_car
|
|
1686
|
-
and active_car.health > 0
|
|
1687
|
-
):
|
|
1688
|
-
if state.has_fuel:
|
|
1689
|
-
player.in_car = True
|
|
1690
|
-
all_sprites.remove(player)
|
|
1691
|
-
state.hint_expires_at = 0
|
|
1692
|
-
state.hint_target_type = None
|
|
1693
|
-
print("Player entered car!")
|
|
1694
|
-
else:
|
|
1695
|
-
if not stage.survival_stage:
|
|
1696
|
-
now_ms = state.elapsed_play_ms
|
|
1697
|
-
state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
|
|
1698
|
-
state.hint_target_type = "fuel"
|
|
1699
|
-
|
|
1700
|
-
# Claim a waiting/parked car when the player finally reaches it
|
|
1701
|
-
if not player.in_car and not active_car and waiting_cars:
|
|
1702
|
-
claimed_car: Car | None = None
|
|
1703
|
-
for parked_car in waiting_cars:
|
|
1704
|
-
if player_near_car(parked_car):
|
|
1705
|
-
claimed_car = parked_car
|
|
1706
|
-
break
|
|
1707
|
-
if claimed_car:
|
|
1708
|
-
if state.has_fuel:
|
|
1709
|
-
try:
|
|
1710
|
-
game_data.waiting_cars.remove(claimed_car)
|
|
1711
|
-
except ValueError:
|
|
1712
|
-
pass
|
|
1713
|
-
game_data.car = claimed_car
|
|
1714
|
-
active_car = claimed_car
|
|
1715
|
-
player.in_car = True
|
|
1716
|
-
all_sprites.remove(player)
|
|
1717
|
-
state.hint_expires_at = 0
|
|
1718
|
-
state.hint_target_type = None
|
|
1719
|
-
apply_passenger_speed_penalty(game_data)
|
|
1720
|
-
maintain_waiting_car_supply(game_data)
|
|
1721
|
-
print("Player claimed a waiting car!")
|
|
1722
|
-
else:
|
|
1723
|
-
if not stage.survival_stage:
|
|
1724
|
-
now_ms = state.elapsed_play_ms
|
|
1725
|
-
state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
|
|
1726
|
-
state.hint_target_type = "fuel"
|
|
1727
|
-
|
|
1728
|
-
# Bonus: collide a parked car while driving to repair/extend capabilities
|
|
1729
|
-
if player.in_car and active_car and shrunk_car and waiting_cars:
|
|
1730
|
-
waiting_group = pygame.sprite.Group(waiting_cars)
|
|
1731
|
-
collided_waiters = pygame.sprite.spritecollide(
|
|
1732
|
-
shrunk_car, waiting_group, False, pygame.sprite.collide_rect
|
|
1733
|
-
)
|
|
1734
|
-
if collided_waiters:
|
|
1735
|
-
removed_any = False
|
|
1736
|
-
capacity_increments = 0
|
|
1737
|
-
for parked in collided_waiters:
|
|
1738
|
-
if not parked.alive():
|
|
1739
|
-
continue
|
|
1740
|
-
parked.kill()
|
|
1741
|
-
try:
|
|
1742
|
-
game_data.waiting_cars.remove(parked)
|
|
1743
|
-
except ValueError:
|
|
1744
|
-
pass
|
|
1745
|
-
active_car.health = active_car.max_health
|
|
1746
|
-
active_car.update_color()
|
|
1747
|
-
removed_any = True
|
|
1748
|
-
if stage.rescue_stage:
|
|
1749
|
-
capacity_increments += 1
|
|
1750
|
-
if removed_any:
|
|
1751
|
-
if capacity_increments:
|
|
1752
|
-
increase_survivor_capacity(game_data, capacity_increments)
|
|
1753
|
-
maintain_waiting_car_supply(game_data)
|
|
1754
|
-
|
|
1755
|
-
# Car hitting zombies
|
|
1756
|
-
if player.in_car and active_car and active_car.health > 0 and shrunk_car:
|
|
1757
|
-
zombies_hit = pygame.sprite.spritecollide(shrunk_car, zombie_group, True)
|
|
1758
|
-
if zombies_hit:
|
|
1759
|
-
active_car.take_damage(CAR_ZOMBIE_DAMAGE * len(zombies_hit))
|
|
1760
|
-
|
|
1761
|
-
if (
|
|
1762
|
-
stage.rescue_stage
|
|
1763
|
-
and player.in_car
|
|
1764
|
-
and active_car
|
|
1765
|
-
and shrunk_car
|
|
1766
|
-
and survivor_group
|
|
1767
|
-
):
|
|
1768
|
-
boarded = pygame.sprite.spritecollide(
|
|
1769
|
-
shrunk_car, survivor_group, True, pygame.sprite.collide_circle
|
|
1770
|
-
)
|
|
1771
|
-
if boarded:
|
|
1772
|
-
state.survivors_onboard += len(boarded)
|
|
1773
|
-
apply_passenger_speed_penalty(game_data)
|
|
1774
|
-
capacity_limit = state.survivor_capacity
|
|
1775
|
-
if state.survivors_onboard > capacity_limit:
|
|
1776
|
-
overload_damage = max(
|
|
1777
|
-
1,
|
|
1778
|
-
int(active_car.max_health * SURVIVOR_OVERLOAD_DAMAGE_RATIO),
|
|
1779
|
-
)
|
|
1780
|
-
add_survivor_message(game_data, tr("survivors.too_many_aboard"))
|
|
1781
|
-
active_car.take_damage(overload_damage)
|
|
1782
|
-
|
|
1783
|
-
if stage.rescue_stage:
|
|
1784
|
-
handle_survivor_zombie_collisions(game_data, config)
|
|
1785
|
-
|
|
1786
|
-
# Handle car destruction
|
|
1787
|
-
if car and car.alive() and car.health <= 0:
|
|
1788
|
-
car_destroyed_pos = car.rect.center
|
|
1789
|
-
car.kill()
|
|
1790
|
-
if stage.rescue_stage:
|
|
1791
|
-
drop_survivors_from_car(game_data, car_destroyed_pos)
|
|
1792
|
-
if player.in_car:
|
|
1793
|
-
player.in_car = False
|
|
1794
|
-
player.x, player.y = car_destroyed_pos[0], car_destroyed_pos[1]
|
|
1795
|
-
player.rect.center = (int(player.x), int(player.y))
|
|
1796
|
-
if player not in all_sprites:
|
|
1797
|
-
all_sprites.add(player, layer=2)
|
|
1798
|
-
print("Car destroyed! Player ejected.")
|
|
1799
|
-
|
|
1800
|
-
# Clear active car and let the player hunt for another waiting car.
|
|
1801
|
-
game_data.car = None
|
|
1802
|
-
state.survivor_capacity = SURVIVOR_MAX_SAFE_PASSENGERS
|
|
1803
|
-
apply_passenger_speed_penalty(game_data)
|
|
1804
|
-
|
|
1805
|
-
# Bring back the buddies near the player after losing the car
|
|
1806
|
-
respawn_buddies_near_player(game_data)
|
|
1807
|
-
maintain_waiting_car_supply(game_data)
|
|
1808
|
-
|
|
1809
|
-
# Player getting caught by zombies
|
|
1810
|
-
if not player.in_car and player in all_sprites:
|
|
1811
|
-
shrunk_player = get_shrunk_sprite(player, 0.8)
|
|
1812
|
-
if pygame.sprite.spritecollide(
|
|
1813
|
-
shrunk_player, zombie_group, False, pygame.sprite.collide_circle
|
|
1814
|
-
):
|
|
1815
|
-
if not state.game_over:
|
|
1816
|
-
state.game_over = True
|
|
1817
|
-
state.game_over_at = pygame.time.get_ticks()
|
|
1818
|
-
state.game_over_message = tr("game_over.scream")
|
|
1819
|
-
|
|
1820
|
-
# Player escaping on foot after dawn (Stage 5)
|
|
1821
|
-
if (
|
|
1822
|
-
stage.survival_stage
|
|
1823
|
-
and state.dawn_ready
|
|
1824
|
-
and not player.in_car
|
|
1825
|
-
and outside_rects
|
|
1826
|
-
and any(outside.collidepoint(player.rect.center) for outside in outside_rects)
|
|
1827
|
-
):
|
|
1828
|
-
state.game_won = True
|
|
1829
|
-
|
|
1830
|
-
# Player escaping the level
|
|
1831
|
-
if player.in_car and car and car.alive() and state.has_fuel:
|
|
1832
|
-
buddies_following = [
|
|
1833
|
-
survivor
|
|
1834
|
-
for survivor in survivor_group
|
|
1835
|
-
if survivor.alive()
|
|
1836
|
-
and survivor.is_buddy
|
|
1837
|
-
and survivor.following
|
|
1838
|
-
and not survivor.rescued
|
|
1839
|
-
]
|
|
1840
|
-
buddy_ready = True
|
|
1841
|
-
if stage.buddy_required_count > 0:
|
|
1842
|
-
total_ready = (
|
|
1843
|
-
state.buddy_rescued + state.buddy_onboard + len(buddies_following)
|
|
1844
|
-
)
|
|
1845
|
-
buddy_ready = total_ready >= stage.buddy_required_count
|
|
1846
|
-
if buddy_ready and any(
|
|
1847
|
-
outside.collidepoint(car.rect.center) for outside in outside_rects
|
|
1848
|
-
):
|
|
1849
|
-
if stage.buddy_required_count > 0:
|
|
1850
|
-
rescued_now = state.buddy_onboard + len(buddies_following)
|
|
1851
|
-
state.buddy_rescued = min(
|
|
1852
|
-
stage.buddy_required_count,
|
|
1853
|
-
state.buddy_rescued + rescued_now,
|
|
1854
|
-
)
|
|
1855
|
-
state.buddy_onboard = 0
|
|
1856
|
-
for buddy in buddies_following:
|
|
1857
|
-
buddy.mark_rescued()
|
|
1858
|
-
if stage.rescue_stage and state.survivors_onboard:
|
|
1859
|
-
state.survivors_rescued += state.survivors_onboard
|
|
1860
|
-
state.survivors_onboard = 0
|
|
1861
|
-
state.next_overload_check_ms = 0
|
|
1862
|
-
apply_passenger_speed_penalty(game_data)
|
|
1863
|
-
state.game_won = True
|
|
1864
|
-
|
|
1865
|
-
# Return fog of view target
|
|
1866
|
-
if not state.game_over and not state.game_won:
|
|
1867
|
-
return car if player.in_car and car and car.alive() else player
|
|
1868
|
-
return None
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
def set_ambient_palette(
|
|
1872
|
-
game_data: GameData, key: str, *, force: bool = False
|
|
1873
|
-
) -> None:
|
|
1874
|
-
"""Apply a named ambient palette to all walls in the level."""
|
|
1875
|
-
|
|
1876
|
-
palette = get_environment_palette(key)
|
|
1877
|
-
state = game_data.state
|
|
1878
|
-
if not force and state.ambient_palette_key == key:
|
|
1879
|
-
return
|
|
1880
|
-
|
|
1881
|
-
state.ambient_palette_key = key
|
|
1882
|
-
_apply_palette_to_walls(game_data, palette, force=True)
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
def sync_ambient_palette_with_flashlights(
|
|
1886
|
-
game_data: GameData, *, force: bool = False
|
|
1887
|
-
) -> None:
|
|
1888
|
-
"""Sync the ambient palette with the player's flashlight inventory."""
|
|
1889
|
-
|
|
1890
|
-
state = game_data.state
|
|
1891
|
-
if state.dawn_ready:
|
|
1892
|
-
set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=force)
|
|
1893
|
-
return
|
|
1894
|
-
key = ambient_palette_key_for_flashlights(state.flashlight_count)
|
|
1895
|
-
set_ambient_palette(game_data, key, force=force)
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
def _apply_palette_to_walls(
|
|
1899
|
-
game_data: GameData,
|
|
1900
|
-
palette,
|
|
1901
|
-
*,
|
|
1902
|
-
force: bool = False,
|
|
1903
|
-
) -> None:
|
|
1904
|
-
if not hasattr(game_data, "groups") or not hasattr(game_data.groups, "wall_group"):
|
|
1905
|
-
return
|
|
1906
|
-
wall_group = game_data.groups.wall_group
|
|
1907
|
-
for wall in wall_group:
|
|
1908
|
-
if not hasattr(wall, "set_palette_colors"):
|
|
1909
|
-
continue
|
|
1910
|
-
category = getattr(wall, "palette_category", "inner_wall")
|
|
1911
|
-
if category == "outer_wall":
|
|
1912
|
-
color = palette.outer_wall
|
|
1913
|
-
border_color = palette.outer_wall_border
|
|
1914
|
-
else:
|
|
1915
|
-
color = palette.inner_wall
|
|
1916
|
-
border_color = palette.inner_wall_border
|
|
1917
|
-
wall.set_palette_colors(color=color, border_color=border_color, force=force)
|