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