zombie-escape 1.7.1__py3-none-any.whl → 1.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zombie_escape/__about__.py +1 -1
- zombie_escape/colors.py +41 -8
- zombie_escape/entities.py +376 -306
- zombie_escape/entities_constants.py +6 -0
- zombie_escape/gameplay/__init__.py +2 -2
- zombie_escape/gameplay/constants.py +1 -7
- zombie_escape/gameplay/footprints.py +2 -2
- zombie_escape/gameplay/interactions.py +4 -10
- zombie_escape/gameplay/layout.py +43 -4
- zombie_escape/gameplay/movement.py +45 -7
- zombie_escape/gameplay/spawn.py +283 -43
- zombie_escape/gameplay/state.py +19 -16
- zombie_escape/gameplay/survivors.py +47 -15
- zombie_escape/gameplay/utils.py +19 -1
- zombie_escape/input_utils.py +167 -0
- zombie_escape/level_blueprints.py +28 -0
- zombie_escape/locales/ui.en.json +55 -11
- zombie_escape/locales/ui.ja.json +54 -10
- zombie_escape/localization.py +28 -0
- zombie_escape/models.py +54 -7
- zombie_escape/render.py +704 -267
- zombie_escape/render_constants.py +12 -0
- zombie_escape/screens/__init__.py +1 -0
- zombie_escape/screens/game_over.py +8 -4
- zombie_escape/screens/gameplay.py +88 -41
- zombie_escape/screens/settings.py +124 -13
- zombie_escape/screens/title.py +111 -0
- zombie_escape/stage_constants.py +116 -3
- zombie_escape/world_grid.py +134 -0
- zombie_escape/zombie_escape.py +68 -61
- {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/METADATA +11 -3
- zombie_escape-1.10.0.dist-info/RECORD +47 -0
- zombie_escape-1.7.1.dist-info/RECORD +0 -45
- {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/WHEEL +0 -0
- {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/gameplay/spawn.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Mapping, Sequence
|
|
3
|
+
from typing import Any, Callable, Literal, Mapping, Sequence
|
|
4
4
|
|
|
5
5
|
import pygame
|
|
6
6
|
|
|
@@ -16,13 +16,15 @@ from ..entities import (
|
|
|
16
16
|
)
|
|
17
17
|
from ..entities_constants import (
|
|
18
18
|
FAST_ZOMBIE_BASE_SPEED,
|
|
19
|
+
FOV_RADIUS,
|
|
19
20
|
PLAYER_SPEED,
|
|
20
21
|
ZOMBIE_AGING_DURATION_FRAMES,
|
|
21
22
|
ZOMBIE_SPEED,
|
|
22
23
|
)
|
|
23
24
|
from ..gameplay_constants import DEFAULT_FLASHLIGHT_SPAWN_COUNT
|
|
24
25
|
from ..level_constants import DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TILE_SIZE
|
|
25
|
-
from ..models import GameData, Stage
|
|
26
|
+
from ..models import DustRing, FallingZombie, GameData, Stage
|
|
27
|
+
from ..render_constants import FLASHLIGHT_FOG_SCALE_STEP, FOG_RADIUS_SCALE
|
|
26
28
|
from ..rng import get_rng
|
|
27
29
|
from .constants import (
|
|
28
30
|
MAX_ZOMBIES,
|
|
@@ -38,6 +40,8 @@ from .utils import (
|
|
|
38
40
|
|
|
39
41
|
RNG = get_rng()
|
|
40
42
|
|
|
43
|
+
FallScheduleResult = Literal["scheduled", "no_position", "blocked", "no_player"]
|
|
44
|
+
|
|
41
45
|
__all__ = [
|
|
42
46
|
"place_new_car",
|
|
43
47
|
"place_fuel_can",
|
|
@@ -49,13 +53,177 @@ __all__ = [
|
|
|
49
53
|
"spawn_waiting_car",
|
|
50
54
|
"maintain_waiting_car_supply",
|
|
51
55
|
"nearest_waiting_car",
|
|
56
|
+
"update_falling_zombies",
|
|
52
57
|
"spawn_exterior_zombie",
|
|
53
58
|
"spawn_weighted_zombie",
|
|
54
59
|
]
|
|
55
60
|
|
|
56
61
|
|
|
57
62
|
def _car_appearance_for_stage(stage: Stage | None) -> str:
|
|
58
|
-
return "disabled" if stage and stage.
|
|
63
|
+
return "disabled" if stage and stage.endurance_stage else "default"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
|
|
67
|
+
normal_ratio = 1.0
|
|
68
|
+
tracker_ratio = 0.0
|
|
69
|
+
wall_follower_ratio = 0.0
|
|
70
|
+
if stage is not None:
|
|
71
|
+
normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
|
|
72
|
+
tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
|
|
73
|
+
wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
|
|
74
|
+
if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
|
|
75
|
+
normal_ratio = 1.0
|
|
76
|
+
tracker_ratio = 0.0
|
|
77
|
+
wall_follower_ratio = 0.0
|
|
78
|
+
if (
|
|
79
|
+
normal_ratio == 1.0
|
|
80
|
+
and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
|
|
81
|
+
and tracker_ratio + wall_follower_ratio <= 1.0
|
|
82
|
+
):
|
|
83
|
+
normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
|
|
84
|
+
total_ratio = normal_ratio + tracker_ratio + wall_follower_ratio
|
|
85
|
+
if total_ratio <= 0:
|
|
86
|
+
return False, False
|
|
87
|
+
pick = RNG.random() * total_ratio
|
|
88
|
+
if pick < normal_ratio:
|
|
89
|
+
return False, False
|
|
90
|
+
if pick < normal_ratio + tracker_ratio:
|
|
91
|
+
return True, False
|
|
92
|
+
return False, True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fov_radius_for_flashlights(flashlight_count: int) -> float:
|
|
96
|
+
count = max(0, int(flashlight_count))
|
|
97
|
+
scale = FOG_RADIUS_SCALE + max(0.0, FLASHLIGHT_FOG_SCALE_STEP) * count
|
|
98
|
+
return FOV_RADIUS * scale
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_spawn_position_clear(
|
|
102
|
+
game_data: GameData,
|
|
103
|
+
candidate: Zombie,
|
|
104
|
+
*,
|
|
105
|
+
allow_player_overlap: bool = False,
|
|
106
|
+
) -> bool:
|
|
107
|
+
wall_group = game_data.groups.wall_group
|
|
108
|
+
if spritecollideany_walls(candidate, wall_group):
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
spawn_rect = candidate.rect
|
|
112
|
+
player = game_data.player
|
|
113
|
+
if not allow_player_overlap and player and spawn_rect.colliderect(player.rect):
|
|
114
|
+
return False
|
|
115
|
+
car = game_data.car
|
|
116
|
+
if car and car.alive() and spawn_rect.colliderect(car.rect):
|
|
117
|
+
return False
|
|
118
|
+
for parked in game_data.waiting_cars:
|
|
119
|
+
if parked.alive() and spawn_rect.colliderect(parked.rect):
|
|
120
|
+
return False
|
|
121
|
+
for survivor in game_data.groups.survivor_group:
|
|
122
|
+
if survivor.alive() and spawn_rect.colliderect(survivor.rect):
|
|
123
|
+
return False
|
|
124
|
+
for zombie in game_data.groups.zombie_group:
|
|
125
|
+
if zombie.alive() and spawn_rect.colliderect(zombie.rect):
|
|
126
|
+
return False
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _pick_fall_spawn_position(
|
|
131
|
+
game_data: GameData,
|
|
132
|
+
*,
|
|
133
|
+
min_distance: float,
|
|
134
|
+
attempts: int = 10,
|
|
135
|
+
is_clear: Callable[[tuple[int, int]], bool] | None = None,
|
|
136
|
+
) -> tuple[int, int] | None:
|
|
137
|
+
player = game_data.player
|
|
138
|
+
if not player:
|
|
139
|
+
return None
|
|
140
|
+
fall_spawn_cells = game_data.layout.fall_spawn_cells
|
|
141
|
+
if not fall_spawn_cells:
|
|
142
|
+
return None
|
|
143
|
+
car = game_data.car
|
|
144
|
+
target_sprite = car if player.in_car and car and car.alive() else player
|
|
145
|
+
target_center = target_sprite.rect.center
|
|
146
|
+
cell_size = game_data.cell_size
|
|
147
|
+
fov_radius = _fov_radius_for_flashlights(game_data.state.flashlight_count)
|
|
148
|
+
min_dist_sq = min_distance * min_distance
|
|
149
|
+
max_dist_sq = fov_radius * fov_radius
|
|
150
|
+
wall_cells = game_data.layout.wall_cells
|
|
151
|
+
|
|
152
|
+
candidates: list[tuple[int, int]] = []
|
|
153
|
+
for cell_x, cell_y in fall_spawn_cells:
|
|
154
|
+
if (cell_x, cell_y) in wall_cells:
|
|
155
|
+
continue
|
|
156
|
+
pos = (
|
|
157
|
+
int(cell_x * cell_size + cell_size // 2),
|
|
158
|
+
int(cell_y * cell_size + cell_size // 2),
|
|
159
|
+
)
|
|
160
|
+
dx = pos[0] - target_center[0]
|
|
161
|
+
dy = pos[1] - target_center[1]
|
|
162
|
+
dist_sq = dx * dx + dy * dy
|
|
163
|
+
if dist_sq < min_dist_sq or dist_sq > max_dist_sq:
|
|
164
|
+
continue
|
|
165
|
+
candidates.append(pos)
|
|
166
|
+
|
|
167
|
+
if not candidates:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
RNG.shuffle(candidates)
|
|
171
|
+
for pos in candidates[: max(1, min(attempts, len(candidates)))]:
|
|
172
|
+
if is_clear is not None and not is_clear(pos):
|
|
173
|
+
continue
|
|
174
|
+
return pos
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _schedule_falling_zombie(
|
|
179
|
+
game_data: GameData,
|
|
180
|
+
config: dict[str, Any],
|
|
181
|
+
*,
|
|
182
|
+
allow_carry: bool = True,
|
|
183
|
+
) -> FallScheduleResult:
|
|
184
|
+
player = game_data.player
|
|
185
|
+
if not player:
|
|
186
|
+
return "no_player"
|
|
187
|
+
state = game_data.state
|
|
188
|
+
zombie_group = game_data.groups.zombie_group
|
|
189
|
+
if len(zombie_group) + len(state.falling_zombies) >= MAX_ZOMBIES:
|
|
190
|
+
return "blocked"
|
|
191
|
+
min_distance = game_data.stage.tile_size * 0.5
|
|
192
|
+
tracker, wall_follower = _pick_zombie_variant(game_data.stage)
|
|
193
|
+
|
|
194
|
+
def _candidate_clear(pos: tuple[int, int]) -> bool:
|
|
195
|
+
candidate = _create_zombie(
|
|
196
|
+
config,
|
|
197
|
+
start_pos=pos,
|
|
198
|
+
stage=game_data.stage,
|
|
199
|
+
tracker=tracker,
|
|
200
|
+
wall_follower=wall_follower,
|
|
201
|
+
)
|
|
202
|
+
return _is_spawn_position_clear(game_data, candidate)
|
|
203
|
+
|
|
204
|
+
spawn_pos = _pick_fall_spawn_position(
|
|
205
|
+
game_data,
|
|
206
|
+
min_distance=min_distance,
|
|
207
|
+
is_clear=_candidate_clear,
|
|
208
|
+
)
|
|
209
|
+
if spawn_pos is None:
|
|
210
|
+
if allow_carry:
|
|
211
|
+
state.falling_spawn_carry += 1
|
|
212
|
+
return "no_position"
|
|
213
|
+
start_offset = game_data.stage.tile_size * 0.7
|
|
214
|
+
start_pos = (int(spawn_pos[0]), int(spawn_pos[1] - start_offset))
|
|
215
|
+
fall = FallingZombie(
|
|
216
|
+
start_pos=start_pos,
|
|
217
|
+
target_pos=(int(spawn_pos[0]), int(spawn_pos[1])),
|
|
218
|
+
started_at_ms=pygame.time.get_ticks(),
|
|
219
|
+
pre_fx_ms=350,
|
|
220
|
+
fall_duration_ms=450,
|
|
221
|
+
dust_duration_ms=220,
|
|
222
|
+
tracker=tracker,
|
|
223
|
+
wall_follower=wall_follower,
|
|
224
|
+
)
|
|
225
|
+
state.falling_zombies.append(fall)
|
|
226
|
+
return "scheduled"
|
|
59
227
|
|
|
60
228
|
|
|
61
229
|
def _create_zombie(
|
|
@@ -75,44 +243,19 @@ def _create_zombie(
|
|
|
75
243
|
else:
|
|
76
244
|
base_speed = ZOMBIE_SPEED
|
|
77
245
|
base_speed = min(base_speed, PLAYER_SPEED - 0.05)
|
|
78
|
-
normal_ratio = 1.0
|
|
79
|
-
tracker_ratio = 0.0
|
|
80
|
-
wall_follower_ratio = 0.0
|
|
81
246
|
if stage is not None:
|
|
82
|
-
normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
|
|
83
|
-
tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
|
|
84
|
-
wall_follower_ratio = max(0.0, min(1.0, stage.zombie_wall_follower_ratio))
|
|
85
|
-
if normal_ratio + tracker_ratio + wall_follower_ratio <= 0:
|
|
86
|
-
normal_ratio = 1.0
|
|
87
|
-
tracker_ratio = 0.0
|
|
88
|
-
wall_follower_ratio = 0.0
|
|
89
|
-
if (
|
|
90
|
-
normal_ratio == 1.0
|
|
91
|
-
and (tracker_ratio > 0.0 or wall_follower_ratio > 0.0)
|
|
92
|
-
and tracker_ratio + wall_follower_ratio <= 1.0
|
|
93
|
-
):
|
|
94
|
-
normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_follower_ratio)
|
|
95
247
|
aging_duration_frames = max(
|
|
96
248
|
1.0,
|
|
97
249
|
float(stage.zombie_aging_duration_frames),
|
|
98
250
|
)
|
|
99
251
|
else:
|
|
100
252
|
aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
pass
|
|
108
|
-
elif pick < normal_ratio + tracker_ratio:
|
|
109
|
-
picked_tracker = True
|
|
110
|
-
else:
|
|
111
|
-
picked_wall_follower = True
|
|
112
|
-
if tracker is None:
|
|
113
|
-
tracker = picked_tracker
|
|
114
|
-
if wall_follower is None:
|
|
115
|
-
wall_follower = picked_wall_follower
|
|
253
|
+
if tracker is None or wall_follower is None:
|
|
254
|
+
picked_tracker, picked_wall_follower = _pick_zombie_variant(stage)
|
|
255
|
+
if tracker is None:
|
|
256
|
+
tracker = picked_tracker
|
|
257
|
+
if wall_follower is None:
|
|
258
|
+
wall_follower = picked_wall_follower
|
|
116
259
|
if tracker:
|
|
117
260
|
wall_follower = False
|
|
118
261
|
if tracker:
|
|
@@ -431,6 +574,34 @@ def spawn_initial_zombies(
|
|
|
431
574
|
if not spawn_cells:
|
|
432
575
|
return
|
|
433
576
|
|
|
577
|
+
if game_data.stage.id == "debug_tracker":
|
|
578
|
+
player_pos = player.rect.center
|
|
579
|
+
min_dist_sq = 100 * 100
|
|
580
|
+
max_dist_sq = 240 * 240
|
|
581
|
+
candidates = [
|
|
582
|
+
cell
|
|
583
|
+
for cell in spawn_cells
|
|
584
|
+
if min_dist_sq
|
|
585
|
+
<= (cell.centerx - player_pos[0]) ** 2 + (cell.centery - player_pos[1]) ** 2
|
|
586
|
+
<= max_dist_sq
|
|
587
|
+
]
|
|
588
|
+
if not candidates:
|
|
589
|
+
candidates = spawn_cells
|
|
590
|
+
candidate = RNG.choice(candidates)
|
|
591
|
+
tentative = _create_zombie(
|
|
592
|
+
config,
|
|
593
|
+
start_pos=candidate.center,
|
|
594
|
+
stage=game_data.stage,
|
|
595
|
+
tracker=True,
|
|
596
|
+
wall_follower=False,
|
|
597
|
+
)
|
|
598
|
+
if not spritecollideany_walls(tentative, wall_group):
|
|
599
|
+
zombie_group.add(tentative)
|
|
600
|
+
all_sprites.add(tentative, layer=1)
|
|
601
|
+
interval = max(1, game_data.stage.spawn_interval_ms)
|
|
602
|
+
game_data.state.last_zombie_spawn_time = pygame.time.get_ticks() - interval
|
|
603
|
+
return
|
|
604
|
+
|
|
434
605
|
spawn_rate = max(0.0, game_data.stage.initial_interior_spawn_rate)
|
|
435
606
|
positions = find_interior_spawn_positions(
|
|
436
607
|
spawn_cells,
|
|
@@ -440,10 +611,13 @@ def spawn_initial_zombies(
|
|
|
440
611
|
)
|
|
441
612
|
|
|
442
613
|
for pos in positions:
|
|
614
|
+
tracker, wall_follower = _pick_zombie_variant(game_data.stage)
|
|
443
615
|
tentative = _create_zombie(
|
|
444
616
|
config,
|
|
445
617
|
start_pos=pos,
|
|
446
618
|
stage=game_data.stage,
|
|
619
|
+
tracker=tracker,
|
|
620
|
+
wall_follower=wall_follower,
|
|
447
621
|
)
|
|
448
622
|
if spritecollideany_walls(tentative, wall_group):
|
|
449
623
|
continue
|
|
@@ -550,6 +724,7 @@ def _spawn_nearby_zombie(
|
|
|
550
724
|
game_data.layout.walkable_cells,
|
|
551
725
|
player=player,
|
|
552
726
|
camera=camera,
|
|
727
|
+
min_player_dist=ZOMBIE_SPAWN_PLAYER_BUFFER,
|
|
553
728
|
attempts=50,
|
|
554
729
|
)
|
|
555
730
|
new_zombie = _create_zombie(
|
|
@@ -589,6 +764,43 @@ def spawn_exterior_zombie(
|
|
|
589
764
|
return new_zombie
|
|
590
765
|
|
|
591
766
|
|
|
767
|
+
def update_falling_zombies(game_data: GameData, config: dict[str, Any]) -> None:
|
|
768
|
+
state = game_data.state
|
|
769
|
+
if not state.falling_zombies:
|
|
770
|
+
return
|
|
771
|
+
now = pygame.time.get_ticks()
|
|
772
|
+
zombie_group = game_data.groups.zombie_group
|
|
773
|
+
all_sprites = game_data.groups.all_sprites
|
|
774
|
+
for fall in list(state.falling_zombies):
|
|
775
|
+
fall_start = fall.started_at_ms + fall.pre_fx_ms
|
|
776
|
+
impact_at = fall_start + fall.fall_duration_ms
|
|
777
|
+
spawn_at = impact_at + fall.dust_duration_ms
|
|
778
|
+
if now >= impact_at and not fall.dust_started:
|
|
779
|
+
state.dust_rings.append(
|
|
780
|
+
DustRing(
|
|
781
|
+
pos=fall.target_pos,
|
|
782
|
+
started_at_ms=impact_at,
|
|
783
|
+
duration_ms=fall.dust_duration_ms,
|
|
784
|
+
)
|
|
785
|
+
)
|
|
786
|
+
fall.dust_started = True
|
|
787
|
+
if now < spawn_at:
|
|
788
|
+
continue
|
|
789
|
+
if len(zombie_group) >= MAX_ZOMBIES:
|
|
790
|
+
state.falling_zombies.remove(fall)
|
|
791
|
+
continue
|
|
792
|
+
candidate = _create_zombie(
|
|
793
|
+
config,
|
|
794
|
+
start_pos=fall.target_pos,
|
|
795
|
+
stage=game_data.stage,
|
|
796
|
+
tracker=fall.tracker,
|
|
797
|
+
wall_follower=fall.wall_follower,
|
|
798
|
+
)
|
|
799
|
+
zombie_group.add(candidate)
|
|
800
|
+
all_sprites.add(candidate, layer=1)
|
|
801
|
+
state.falling_zombies.remove(fall)
|
|
802
|
+
|
|
803
|
+
|
|
592
804
|
def spawn_weighted_zombie(
|
|
593
805
|
game_data: GameData,
|
|
594
806
|
config: dict[str, Any],
|
|
@@ -596,23 +808,51 @@ def spawn_weighted_zombie(
|
|
|
596
808
|
"""Spawn a zombie according to the stage's interior/exterior mix."""
|
|
597
809
|
stage = game_data.stage
|
|
598
810
|
|
|
599
|
-
def
|
|
600
|
-
|
|
601
|
-
|
|
811
|
+
def _spawn_interior() -> bool:
|
|
812
|
+
return _spawn_nearby_zombie(game_data, config) is not None
|
|
813
|
+
|
|
814
|
+
def _spawn_exterior() -> bool:
|
|
602
815
|
return spawn_exterior_zombie(game_data, config) is not None
|
|
603
816
|
|
|
817
|
+
def _spawn_fall() -> FallScheduleResult:
|
|
818
|
+
result = _schedule_falling_zombie(game_data, config)
|
|
819
|
+
if result != "scheduled":
|
|
820
|
+
return result
|
|
821
|
+
state = game_data.state
|
|
822
|
+
if state.falling_spawn_carry > 0:
|
|
823
|
+
extra = _schedule_falling_zombie(
|
|
824
|
+
game_data,
|
|
825
|
+
config,
|
|
826
|
+
allow_carry=False,
|
|
827
|
+
)
|
|
828
|
+
if extra == "scheduled":
|
|
829
|
+
state.falling_spawn_carry = max(0, state.falling_spawn_carry - 1)
|
|
830
|
+
return "scheduled"
|
|
831
|
+
|
|
604
832
|
interior_weight = max(0.0, stage.interior_spawn_weight)
|
|
605
833
|
exterior_weight = max(0.0, stage.exterior_spawn_weight)
|
|
606
|
-
|
|
834
|
+
fall_weight = max(0.0, getattr(stage, "interior_fall_spawn_weight", 0.0))
|
|
835
|
+
total_weight = interior_weight + exterior_weight + fall_weight
|
|
607
836
|
if total_weight <= 0:
|
|
608
837
|
# Fall back to exterior spawns if weights are unset or invalid.
|
|
609
|
-
return
|
|
838
|
+
return _spawn_exterior()
|
|
610
839
|
|
|
611
840
|
pick = RNG.uniform(0, total_weight)
|
|
612
841
|
if pick <= interior_weight:
|
|
613
|
-
if
|
|
842
|
+
if _spawn_interior():
|
|
614
843
|
return True
|
|
615
|
-
|
|
616
|
-
|
|
844
|
+
fall_result = _spawn_fall()
|
|
845
|
+
if fall_result == "scheduled":
|
|
846
|
+
return True
|
|
847
|
+
return False
|
|
848
|
+
if pick <= interior_weight + fall_weight:
|
|
849
|
+
fall_result = _spawn_fall()
|
|
850
|
+
if fall_result == "scheduled":
|
|
851
|
+
return True
|
|
852
|
+
return False
|
|
853
|
+
if _spawn_exterior():
|
|
854
|
+
return True
|
|
855
|
+
fall_result = _spawn_fall()
|
|
856
|
+
if fall_result == "scheduled":
|
|
617
857
|
return True
|
|
618
|
-
return
|
|
858
|
+
return False
|
zombie_escape/gameplay/state.py
CHANGED
|
@@ -14,7 +14,7 @@ from .ambient import _set_ambient_palette
|
|
|
14
14
|
def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
15
15
|
"""Initialize and return the base game state objects."""
|
|
16
16
|
starts_with_fuel = not stage.requires_fuel
|
|
17
|
-
if stage.
|
|
17
|
+
if stage.endurance_stage:
|
|
18
18
|
starts_with_fuel = False
|
|
19
19
|
starts_with_flashlight = False
|
|
20
20
|
initial_flashlights = 1 if starts_with_flashlight else 0
|
|
@@ -42,14 +42,17 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
42
42
|
survivor_messages=[],
|
|
43
43
|
survivor_capacity=SURVIVOR_MAX_SAFE_PASSENGERS,
|
|
44
44
|
seed=None,
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
endurance_elapsed_ms=0,
|
|
46
|
+
endurance_goal_ms=max(0, stage.endurance_goal_ms),
|
|
47
47
|
dawn_ready=False,
|
|
48
48
|
dawn_prompt_at=None,
|
|
49
49
|
time_accel_active=False,
|
|
50
50
|
last_zombie_spawn_time=0,
|
|
51
51
|
dawn_carbonized=False,
|
|
52
52
|
debug_mode=False,
|
|
53
|
+
falling_zombies=[],
|
|
54
|
+
falling_spawn_carry=0,
|
|
55
|
+
dust_rings=[],
|
|
53
56
|
)
|
|
54
57
|
|
|
55
58
|
# Create sprite groups
|
|
@@ -83,6 +86,9 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
83
86
|
outside_rects=[],
|
|
84
87
|
walkable_cells=[],
|
|
85
88
|
outer_wall_cells=set(),
|
|
89
|
+
wall_cells=set(),
|
|
90
|
+
fall_spawn_cells=set(),
|
|
91
|
+
bevel_corners={},
|
|
86
92
|
),
|
|
87
93
|
fog={
|
|
88
94
|
"hatch_patterns": {},
|
|
@@ -106,29 +112,26 @@ def carbonize_outdoor_zombies(game_data: GameData) -> None:
|
|
|
106
112
|
if not group:
|
|
107
113
|
return
|
|
108
114
|
for zombie in list(group):
|
|
109
|
-
|
|
110
|
-
if not alive():
|
|
115
|
+
if not zombie.alive():
|
|
111
116
|
continue
|
|
112
117
|
center = zombie.rect.center
|
|
113
118
|
if any(rect_obj.collidepoint(center) for rect_obj in outside_rects):
|
|
114
|
-
carbonize
|
|
115
|
-
if carbonize:
|
|
116
|
-
carbonize()
|
|
119
|
+
zombie.carbonize()
|
|
117
120
|
|
|
118
121
|
|
|
119
|
-
def
|
|
120
|
-
"""Advance the
|
|
122
|
+
def update_endurance_timer(game_data: GameData, dt_ms: int) -> None:
|
|
123
|
+
"""Advance the endurance countdown and trigger dawn handoff."""
|
|
121
124
|
stage = game_data.stage
|
|
122
125
|
state = game_data.state
|
|
123
|
-
if not stage.
|
|
126
|
+
if not stage.endurance_stage:
|
|
124
127
|
return
|
|
125
|
-
if state.
|
|
128
|
+
if state.endurance_goal_ms <= 0 or dt_ms <= 0:
|
|
126
129
|
return
|
|
127
|
-
state.
|
|
128
|
-
state.
|
|
129
|
-
state.
|
|
130
|
+
state.endurance_elapsed_ms = min(
|
|
131
|
+
state.endurance_goal_ms,
|
|
132
|
+
state.endurance_elapsed_ms + dt_ms,
|
|
130
133
|
)
|
|
131
|
-
if not state.dawn_ready and state.
|
|
134
|
+
if not state.dawn_ready and state.endurance_elapsed_ms >= state.endurance_goal_ms:
|
|
132
135
|
state.dawn_ready = True
|
|
133
136
|
state.dawn_prompt_at = pygame.time.get_ticks()
|
|
134
137
|
_set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=True)
|
|
@@ -16,15 +16,12 @@ from ..entities_constants import (
|
|
|
16
16
|
SURVIVOR_RADIUS,
|
|
17
17
|
ZOMBIE_RADIUS,
|
|
18
18
|
)
|
|
19
|
-
from .constants import
|
|
20
|
-
|
|
21
|
-
SURVIVOR_MESSAGE_DURATION_MS,
|
|
22
|
-
SURVIVOR_SPEED_PENALTY_PER_PASSENGER,
|
|
23
|
-
)
|
|
24
|
-
from ..localization import translate as tr
|
|
19
|
+
from .constants import SURVIVOR_MESSAGE_DURATION_MS, SURVIVOR_SPEED_PENALTY_PER_PASSENGER
|
|
20
|
+
from ..localization import translate_dict, translate_list
|
|
25
21
|
from ..models import GameData, ProgressState
|
|
26
22
|
from ..rng import get_rng
|
|
27
|
-
from ..entities import Survivor,
|
|
23
|
+
from ..entities import Survivor, Zombie, spritecollideany_walls
|
|
24
|
+
from ..world_grid import WallIndex
|
|
28
25
|
from .spawn import _create_zombie
|
|
29
26
|
from .utils import find_nearby_offscreen_spawn_position, rect_visible_on_screen
|
|
30
27
|
|
|
@@ -51,6 +48,10 @@ def update_survivors(
|
|
|
51
48
|
wall_group,
|
|
52
49
|
wall_index=wall_index,
|
|
53
50
|
cell_size=game_data.cell_size,
|
|
51
|
+
wall_cells=game_data.layout.wall_cells,
|
|
52
|
+
bevel_corners=game_data.layout.bevel_corners,
|
|
53
|
+
grid_cols=game_data.stage.grid_cols,
|
|
54
|
+
grid_rows=game_data.stage.grid_rows,
|
|
54
55
|
level_width=game_data.level_width,
|
|
55
56
|
level_height=game_data.level_height,
|
|
56
57
|
)
|
|
@@ -150,11 +151,40 @@ def add_survivor_message(game_data: GameData, text: str) -> None:
|
|
|
150
151
|
game_data.state.survivor_messages.append({"text": text, "expires_at": expires})
|
|
151
152
|
|
|
152
153
|
|
|
153
|
-
def
|
|
154
|
-
|
|
154
|
+
def _normalize_legacy_conversion_lines(data: dict[str, Any]) -> list[str]:
|
|
155
|
+
numbered: list[tuple[int, str]] = []
|
|
156
|
+
others: list[tuple[str, str]] = []
|
|
157
|
+
for key, value in data.items():
|
|
158
|
+
if not value:
|
|
159
|
+
continue
|
|
160
|
+
text = str(value)
|
|
161
|
+
if isinstance(key, str) and key.startswith("line"):
|
|
162
|
+
suffix = key[4:]
|
|
163
|
+
if suffix.isdigit():
|
|
164
|
+
numbered.append((int(suffix), text))
|
|
165
|
+
continue
|
|
166
|
+
others.append((str(key), text))
|
|
167
|
+
numbered.sort(key=lambda item: item[0])
|
|
168
|
+
others.sort(key=lambda item: item[0])
|
|
169
|
+
return [text for _, text in numbered] + [text for _, text in others]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _get_survivor_conversion_messages(stage_id: str) -> list[str]:
|
|
173
|
+
key = f"stages.{stage_id}.survivor_conversion_messages"
|
|
174
|
+
raw = translate_list(key)
|
|
175
|
+
if raw:
|
|
176
|
+
return [str(item) for item in raw if item]
|
|
177
|
+
legacy = translate_dict(f"stages.{stage_id}.conversion_lines")
|
|
178
|
+
if legacy:
|
|
179
|
+
return _normalize_legacy_conversion_lines(legacy)
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def random_survivor_conversion_line(stage_id: str) -> str:
|
|
184
|
+
lines = _get_survivor_conversion_messages(stage_id)
|
|
185
|
+
if not lines:
|
|
155
186
|
return ""
|
|
156
|
-
|
|
157
|
-
return tr(key)
|
|
187
|
+
return RNG.choice(lines)
|
|
158
188
|
|
|
159
189
|
|
|
160
190
|
def cleanup_survivor_messages(state: ProgressState) -> None:
|
|
@@ -224,7 +254,7 @@ def handle_survivor_zombie_collisions(
|
|
|
224
254
|
min_x = survivor.rect.centerx - search_radius
|
|
225
255
|
max_x = survivor.rect.centerx + search_radius
|
|
226
256
|
start_idx = bisect_left(zombie_xs, min_x)
|
|
227
|
-
|
|
257
|
+
collided_zombie: Zombie | None = None
|
|
228
258
|
for idx in range(start_idx, len(zombies)):
|
|
229
259
|
zombie_x = zombie_xs[idx]
|
|
230
260
|
if zombie_x > max_x:
|
|
@@ -237,10 +267,10 @@ def handle_survivor_zombie_collisions(
|
|
|
237
267
|
continue
|
|
238
268
|
dx = zombie_x - survivor.rect.centerx
|
|
239
269
|
if dx * dx + dy * dy <= search_radius_sq:
|
|
240
|
-
|
|
270
|
+
collided_zombie = zombie
|
|
241
271
|
break
|
|
242
272
|
|
|
243
|
-
if
|
|
273
|
+
if collided_zombie is None:
|
|
244
274
|
continue
|
|
245
275
|
if not rect_visible_on_screen(camera, survivor.rect):
|
|
246
276
|
spawn_pos = find_nearby_offscreen_spawn_position(
|
|
@@ -250,13 +280,15 @@ def handle_survivor_zombie_collisions(
|
|
|
250
280
|
survivor.teleport(spawn_pos)
|
|
251
281
|
continue
|
|
252
282
|
survivor.kill()
|
|
253
|
-
line = random_survivor_conversion_line()
|
|
283
|
+
line = random_survivor_conversion_line(game_data.stage.id)
|
|
254
284
|
if line:
|
|
255
285
|
add_survivor_message(game_data, line)
|
|
256
286
|
new_zombie = _create_zombie(
|
|
257
287
|
config,
|
|
258
288
|
start_pos=survivor.rect.center,
|
|
259
289
|
stage=game_data.stage,
|
|
290
|
+
tracker=collided_zombie.tracker,
|
|
291
|
+
wall_follower=collided_zombie.wall_follower,
|
|
260
292
|
)
|
|
261
293
|
zombie_group.add(new_zombie)
|
|
262
294
|
game_data.groups.all_sprites.add(new_zombie, layer=1)
|
zombie_escape/gameplay/utils.py
CHANGED
|
@@ -108,7 +108,9 @@ def find_nearby_offscreen_spawn_position(
|
|
|
108
108
|
jitter_x = RNG.uniform(-cell.width * 0.35, cell.width * 0.35)
|
|
109
109
|
jitter_y = RNG.uniform(-cell.height * 0.35, cell.height * 0.35)
|
|
110
110
|
candidate = (int(cell.centerx + jitter_x), int(cell.centery + jitter_y))
|
|
111
|
-
if player is not None and (
|
|
111
|
+
if player is not None and (
|
|
112
|
+
min_distance_sq is not None or max_distance_sq is not None
|
|
113
|
+
):
|
|
112
114
|
dx = candidate[0] - player.x
|
|
113
115
|
dy = candidate[1] - player.y
|
|
114
116
|
dist_sq = dx * dx + dy * dy
|
|
@@ -119,6 +121,22 @@ def find_nearby_offscreen_spawn_position(
|
|
|
119
121
|
if view_rect is not None and view_rect.collidepoint(candidate):
|
|
120
122
|
continue
|
|
121
123
|
return candidate
|
|
124
|
+
if player is not None and (min_distance_sq is not None or max_distance_sq is not None):
|
|
125
|
+
for _ in range(20):
|
|
126
|
+
cell = RNG.choice(walkable_cells)
|
|
127
|
+
center = (cell.centerx, cell.centery)
|
|
128
|
+
if view_rect is not None and view_rect.collidepoint(center):
|
|
129
|
+
continue
|
|
130
|
+
dx = center[0] - player.x
|
|
131
|
+
dy = center[1] - player.y
|
|
132
|
+
dist_sq = dx * dx + dy * dy
|
|
133
|
+
if min_distance_sq is not None and dist_sq < min_distance_sq:
|
|
134
|
+
continue
|
|
135
|
+
if max_distance_sq is not None and dist_sq > max_distance_sq:
|
|
136
|
+
continue
|
|
137
|
+
fallback_x = RNG.uniform(-cell.width * 0.2, cell.width * 0.2)
|
|
138
|
+
fallback_y = RNG.uniform(-cell.height * 0.2, cell.height * 0.2)
|
|
139
|
+
return (int(cell.centerx + fallback_x), int(cell.centery + fallback_y))
|
|
122
140
|
fallback_cell = RNG.choice(walkable_cells)
|
|
123
141
|
fallback_x = RNG.uniform(-fallback_cell.width * 0.35, fallback_cell.width * 0.35)
|
|
124
142
|
fallback_y = RNG.uniform(-fallback_cell.height * 0.35, fallback_cell.height * 0.35)
|