zombie-escape 1.8.0__py3-none-any.whl → 1.10.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/colors.py +49 -14
- zombie_escape/entities.py +295 -332
- zombie_escape/entities_constants.py +6 -0
- zombie_escape/gameplay/__init__.py +2 -2
- zombie_escape/gameplay/constants.py +1 -1
- zombie_escape/gameplay/footprints.py +2 -2
- zombie_escape/gameplay/interactions.py +4 -10
- zombie_escape/gameplay/layout.py +38 -4
- zombie_escape/gameplay/movement.py +5 -7
- zombie_escape/gameplay/spawn.py +245 -12
- zombie_escape/gameplay/state.py +17 -16
- zombie_escape/gameplay/survivors.py +5 -4
- zombie_escape/gameplay/utils.py +19 -1
- zombie_escape/locales/ui.en.json +14 -2
- zombie_escape/locales/ui.ja.json +14 -2
- zombie_escape/models.py +52 -7
- zombie_escape/render.py +760 -284
- zombie_escape/render_constants.py +26 -8
- zombie_escape/screens/__init__.py +1 -0
- zombie_escape/screens/game_over.py +4 -4
- zombie_escape/screens/gameplay.py +10 -24
- zombie_escape/stage_constants.py +90 -2
- zombie_escape/world_grid.py +134 -0
- zombie_escape/zombie_escape.py +65 -61
- {zombie_escape-1.8.0.dist-info → zombie_escape-1.10.1.dist-info}/METADATA +9 -1
- zombie_escape-1.10.1.dist-info/RECORD +47 -0
- zombie_escape-1.8.0.dist-info/RECORD +0 -46
- {zombie_escape-1.8.0.dist-info → zombie_escape-1.10.1.dist-info}/WHEEL +0 -0
- {zombie_escape-1.8.0.dist-info → zombie_escape-1.10.1.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.8.0.dist-info → zombie_escape-1.10.1.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -34,6 +34,9 @@ ZOMBIE_SEPARATION_DISTANCE = ZOMBIE_RADIUS * 2.2
|
|
|
34
34
|
ZOMBIE_AGING_DURATION_FRAMES = FPS * 60 * 6 # ~6 minutes at target framerate
|
|
35
35
|
ZOMBIE_AGING_MIN_SPEED_RATIO = 0.3
|
|
36
36
|
ZOMBIE_TRACKER_SCENT_RADIUS = 70
|
|
37
|
+
ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER = 2
|
|
38
|
+
ZOMBIE_TRACKER_SCENT_TOP_K = 3
|
|
39
|
+
ZOMBIE_TRACKER_SCAN_INTERVAL_MS = int(1000 * 30 / FPS)
|
|
37
40
|
ZOMBIE_TRACKER_WANDER_INTERVAL_MS = 2500
|
|
38
41
|
ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE = 24
|
|
39
42
|
ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG = 45
|
|
@@ -81,6 +84,9 @@ __all__ = [
|
|
|
81
84
|
"ZOMBIE_AGING_DURATION_FRAMES",
|
|
82
85
|
"ZOMBIE_AGING_MIN_SPEED_RATIO",
|
|
83
86
|
"ZOMBIE_TRACKER_SCENT_RADIUS",
|
|
87
|
+
"ZOMBIE_TRACKER_SCAN_RADIUS_MULTIPLIER",
|
|
88
|
+
"ZOMBIE_TRACKER_SCENT_TOP_K",
|
|
89
|
+
"ZOMBIE_TRACKER_SCAN_INTERVAL_MS",
|
|
84
90
|
"ZOMBIE_TRACKER_WANDER_INTERVAL_MS",
|
|
85
91
|
"ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE",
|
|
86
92
|
"ZOMBIE_WALL_FOLLOW_PROBE_ANGLE_DEG",
|
|
@@ -21,7 +21,7 @@ from .spawn import (
|
|
|
21
21
|
spawn_waiting_car,
|
|
22
22
|
spawn_weighted_zombie,
|
|
23
23
|
)
|
|
24
|
-
from .state import carbonize_outdoor_zombies, initialize_game_state,
|
|
24
|
+
from .state import carbonize_outdoor_zombies, initialize_game_state, update_endurance_timer
|
|
25
25
|
from .survivors import (
|
|
26
26
|
add_survivor_message,
|
|
27
27
|
apply_passenger_speed_penalty,
|
|
@@ -71,7 +71,7 @@ __all__ = [
|
|
|
71
71
|
"initialize_game_state",
|
|
72
72
|
"setup_player_and_cars",
|
|
73
73
|
"spawn_initial_zombies",
|
|
74
|
-
"
|
|
74
|
+
"update_endurance_timer",
|
|
75
75
|
"carbonize_outdoor_zombies",
|
|
76
76
|
"process_player_input",
|
|
77
77
|
"update_entities",
|
|
@@ -5,7 +5,7 @@ from typing import Any
|
|
|
5
5
|
import pygame
|
|
6
6
|
|
|
7
7
|
from .constants import FOOTPRINT_MAX, FOOTPRINT_STEP_DISTANCE
|
|
8
|
-
from ..models import GameData
|
|
8
|
+
from ..models import Footprint, GameData
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def get_shrunk_sprite(
|
|
@@ -51,7 +51,7 @@ def update_footprints(game_data: GameData, config: dict[str, Any]) -> None:
|
|
|
51
51
|
dist_sq is not None
|
|
52
52
|
and dist_sq >= FOOTPRINT_STEP_DISTANCE * FOOTPRINT_STEP_DISTANCE
|
|
53
53
|
):
|
|
54
|
-
footprints.append(
|
|
54
|
+
footprints.append(Footprint(pos=(player.x, player.y), time=now))
|
|
55
55
|
state.last_footprint_pos = (player.x, player.y)
|
|
56
56
|
|
|
57
57
|
if len(footprints) > FOOTPRINT_MAX:
|
|
@@ -45,9 +45,7 @@ def _interaction_radius(width: float, height: float) -> float:
|
|
|
45
45
|
RNG = get_rng()
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def check_interactions(
|
|
49
|
-
game_data: GameData, config: dict[str, Any]
|
|
50
|
-
) -> pygame.sprite.Sprite | None:
|
|
48
|
+
def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
|
|
51
49
|
"""Check and handle interactions between entities."""
|
|
52
50
|
player = game_data.player
|
|
53
51
|
assert player is not None
|
|
@@ -193,7 +191,7 @@ def check_interactions(
|
|
|
193
191
|
state.hint_target_type = None
|
|
194
192
|
print("Player entered car!")
|
|
195
193
|
else:
|
|
196
|
-
if not stage.
|
|
194
|
+
if not stage.endurance_stage:
|
|
197
195
|
now_ms = state.elapsed_play_ms
|
|
198
196
|
state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
|
|
199
197
|
state.hint_target_type = "fuel"
|
|
@@ -221,7 +219,7 @@ def check_interactions(
|
|
|
221
219
|
maintain_waiting_car_supply(game_data)
|
|
222
220
|
print("Player claimed a waiting car!")
|
|
223
221
|
else:
|
|
224
|
-
if not stage.
|
|
222
|
+
if not stage.endurance_stage:
|
|
225
223
|
now_ms = state.elapsed_play_ms
|
|
226
224
|
state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
|
|
227
225
|
state.hint_target_type = "fuel"
|
|
@@ -321,7 +319,7 @@ def check_interactions(
|
|
|
321
319
|
|
|
322
320
|
# Player escaping on foot after dawn (Stage 5)
|
|
323
321
|
if (
|
|
324
|
-
stage.
|
|
322
|
+
stage.endurance_stage
|
|
325
323
|
and state.dawn_ready
|
|
326
324
|
and not player.in_car
|
|
327
325
|
and outside_rects
|
|
@@ -344,11 +342,7 @@ def check_interactions(
|
|
|
344
342
|
if stage.rescue_stage and state.survivors_onboard:
|
|
345
343
|
state.survivors_rescued += state.survivors_onboard
|
|
346
344
|
state.survivors_onboard = 0
|
|
347
|
-
state.next_overload_check_ms = 0
|
|
348
345
|
apply_passenger_speed_penalty(game_data)
|
|
349
346
|
state.game_won = True
|
|
350
347
|
|
|
351
|
-
# Return fog of view target
|
|
352
|
-
if not state.game_over and not state.game_won:
|
|
353
|
-
return car if player.in_car and car and car.alive() else player
|
|
354
348
|
return None
|
zombie_escape/gameplay/layout.py
CHANGED
|
@@ -23,6 +23,26 @@ def _rect_for_cell(x_idx: int, y_idx: int, cell_size: int) -> pygame.Rect:
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _expand_zone_cells(
|
|
27
|
+
zones: list[tuple[int, int, int, int]],
|
|
28
|
+
*,
|
|
29
|
+
grid_cols: int,
|
|
30
|
+
grid_rows: int,
|
|
31
|
+
) -> set[tuple[int, int]]:
|
|
32
|
+
cells: set[tuple[int, int]] = set()
|
|
33
|
+
for col, row, width, height in zones:
|
|
34
|
+
if width <= 0 or height <= 0:
|
|
35
|
+
continue
|
|
36
|
+
start_x = max(0, col)
|
|
37
|
+
start_y = max(0, row)
|
|
38
|
+
end_x = min(grid_cols, col + width)
|
|
39
|
+
end_y = min(grid_rows, row + height)
|
|
40
|
+
for y in range(start_y, end_y):
|
|
41
|
+
for x in range(start_x, end_x):
|
|
42
|
+
cells.add((x, y))
|
|
43
|
+
return cells
|
|
44
|
+
|
|
45
|
+
|
|
26
46
|
def generate_level_from_blueprint(
|
|
27
47
|
game_data: GameData, config: dict[str, Any]
|
|
28
48
|
) -> dict[str, list[pygame.Rect]]:
|
|
@@ -78,12 +98,16 @@ def generate_level_from_blueprint(
|
|
|
78
98
|
palette = get_environment_palette(game_data.state.ambient_palette_key)
|
|
79
99
|
|
|
80
100
|
def add_beam_to_groups(beam: SteelBeam) -> None:
|
|
81
|
-
if
|
|
101
|
+
if beam._added_to_groups:
|
|
82
102
|
return
|
|
83
103
|
wall_group.add(beam)
|
|
84
104
|
all_sprites.add(beam, layer=0)
|
|
85
105
|
beam._added_to_groups = True
|
|
86
106
|
|
|
107
|
+
def remove_wall_cell(cell: tuple[int, int]) -> None:
|
|
108
|
+
wall_cells.discard(cell)
|
|
109
|
+
outer_wall_cells.discard(cell)
|
|
110
|
+
|
|
87
111
|
for y, row in enumerate(blueprint):
|
|
88
112
|
if len(row) != stage.grid_cols:
|
|
89
113
|
raise ValueError(
|
|
@@ -98,6 +122,7 @@ def generate_level_from_blueprint(
|
|
|
98
122
|
continue
|
|
99
123
|
if ch == "B":
|
|
100
124
|
draw_bottom_side = not _has_wall(x, y + 1)
|
|
125
|
+
wall_cell = (x, y)
|
|
101
126
|
wall = Wall(
|
|
102
127
|
cell_rect.x,
|
|
103
128
|
cell_rect.y,
|
|
@@ -108,6 +133,7 @@ def generate_level_from_blueprint(
|
|
|
108
133
|
palette_category="outer_wall",
|
|
109
134
|
bevel_depth=0,
|
|
110
135
|
draw_bottom_side=draw_bottom_side,
|
|
136
|
+
on_destroy=(lambda _w, cell=wall_cell: remove_wall_cell(cell)),
|
|
111
137
|
)
|
|
112
138
|
wall_group.add(wall)
|
|
113
139
|
all_sprites.add(wall, layer=0)
|
|
@@ -142,6 +168,7 @@ def generate_level_from_blueprint(
|
|
|
142
168
|
)
|
|
143
169
|
if any(bevel_mask):
|
|
144
170
|
bevel_corners[(x, y)] = bevel_mask
|
|
171
|
+
wall_cell = (x, y)
|
|
145
172
|
wall = Wall(
|
|
146
173
|
cell_rect.x,
|
|
147
174
|
cell_rect.y,
|
|
@@ -152,9 +179,11 @@ def generate_level_from_blueprint(
|
|
|
152
179
|
palette_category="inner_wall",
|
|
153
180
|
bevel_mask=bevel_mask,
|
|
154
181
|
draw_bottom_side=draw_bottom_side,
|
|
155
|
-
on_destroy=(
|
|
156
|
-
|
|
157
|
-
|
|
182
|
+
on_destroy=(
|
|
183
|
+
(lambda _w, b=beam, cell=wall_cell: (remove_wall_cell(cell), add_beam_to_groups(b)))
|
|
184
|
+
if beam
|
|
185
|
+
else (lambda _w, cell=wall_cell: remove_wall_cell(cell))
|
|
186
|
+
),
|
|
158
187
|
)
|
|
159
188
|
wall_group.add(wall)
|
|
160
189
|
all_sprites.add(wall, layer=0)
|
|
@@ -185,6 +214,11 @@ def generate_level_from_blueprint(
|
|
|
185
214
|
game_data.layout.walkable_cells = walkable_cells
|
|
186
215
|
game_data.layout.outer_wall_cells = outer_wall_cells
|
|
187
216
|
game_data.layout.wall_cells = wall_cells
|
|
217
|
+
game_data.layout.fall_spawn_cells = _expand_zone_cells(
|
|
218
|
+
stage.fall_spawn_zones,
|
|
219
|
+
grid_cols=stage.grid_cols,
|
|
220
|
+
grid_rows=stage.grid_rows,
|
|
221
|
+
)
|
|
188
222
|
game_data.layout.bevel_corners = bevel_corners
|
|
189
223
|
|
|
190
224
|
return {
|
|
@@ -10,20 +10,17 @@ from ..entities import (
|
|
|
10
10
|
Player,
|
|
11
11
|
Survivor,
|
|
12
12
|
Wall,
|
|
13
|
-
WallIndex,
|
|
14
13
|
Zombie,
|
|
15
|
-
apply_tile_edge_nudge,
|
|
16
|
-
walls_for_radius,
|
|
17
14
|
)
|
|
18
15
|
from ..entities_constants import (
|
|
19
|
-
CAR_SPEED,
|
|
20
16
|
PLAYER_SPEED,
|
|
21
17
|
ZOMBIE_SEPARATION_DISTANCE,
|
|
22
18
|
ZOMBIE_WALL_FOLLOW_SENSOR_DISTANCE,
|
|
23
19
|
)
|
|
24
20
|
from ..models import GameData
|
|
21
|
+
from ..world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
|
|
25
22
|
from .constants import MAX_ZOMBIES
|
|
26
|
-
from .spawn import spawn_weighted_zombie
|
|
23
|
+
from .spawn import spawn_weighted_zombie, update_falling_zombies
|
|
27
24
|
from .survivors import update_survivors
|
|
28
25
|
from .utils import rect_visible_on_screen
|
|
29
26
|
|
|
@@ -50,7 +47,7 @@ def process_player_input(
|
|
|
50
47
|
player_dx, player_dy, car_dx, car_dy = 0, 0, 0, 0
|
|
51
48
|
|
|
52
49
|
if player.in_car and car and car.alive():
|
|
53
|
-
target_speed =
|
|
50
|
+
target_speed = car.speed
|
|
54
51
|
move_len = math.hypot(dx_input, dy_input)
|
|
55
52
|
if move_len > 0:
|
|
56
53
|
car_dx, car_dy = (
|
|
@@ -156,11 +153,12 @@ def update_entities(
|
|
|
156
153
|
camera.update(target_for_camera)
|
|
157
154
|
|
|
158
155
|
update_survivors(game_data, wall_index=wall_index)
|
|
156
|
+
update_falling_zombies(game_data, config)
|
|
159
157
|
|
|
160
158
|
# Spawn new zombies if needed
|
|
161
159
|
current_time = pygame.time.get_ticks()
|
|
162
160
|
spawn_interval = max(1, stage.spawn_interval_ms)
|
|
163
|
-
spawn_blocked = stage.
|
|
161
|
+
spawn_blocked = stage.endurance_stage and game_data.state.dawn_ready
|
|
164
162
|
if (
|
|
165
163
|
len(zombie_group) < MAX_ZOMBIES
|
|
166
164
|
and not spawn_blocked
|
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,14 @@ __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"
|
|
59
64
|
|
|
60
65
|
|
|
61
66
|
def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
|
|
@@ -87,6 +92,140 @@ def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
|
|
|
87
92
|
return False, True
|
|
88
93
|
|
|
89
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"
|
|
227
|
+
|
|
228
|
+
|
|
90
229
|
def _create_zombie(
|
|
91
230
|
config: dict[str, Any],
|
|
92
231
|
*,
|
|
@@ -435,6 +574,34 @@ def spawn_initial_zombies(
|
|
|
435
574
|
if not spawn_cells:
|
|
436
575
|
return
|
|
437
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
|
+
|
|
438
605
|
spawn_rate = max(0.0, game_data.stage.initial_interior_spawn_rate)
|
|
439
606
|
positions = find_interior_spawn_positions(
|
|
440
607
|
spawn_cells,
|
|
@@ -557,6 +724,7 @@ def _spawn_nearby_zombie(
|
|
|
557
724
|
game_data.layout.walkable_cells,
|
|
558
725
|
player=player,
|
|
559
726
|
camera=camera,
|
|
727
|
+
min_player_dist=ZOMBIE_SPAWN_PLAYER_BUFFER,
|
|
560
728
|
attempts=50,
|
|
561
729
|
)
|
|
562
730
|
new_zombie = _create_zombie(
|
|
@@ -596,6 +764,43 @@ def spawn_exterior_zombie(
|
|
|
596
764
|
return new_zombie
|
|
597
765
|
|
|
598
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
|
+
|
|
599
804
|
def spawn_weighted_zombie(
|
|
600
805
|
game_data: GameData,
|
|
601
806
|
config: dict[str, Any],
|
|
@@ -603,23 +808,51 @@ def spawn_weighted_zombie(
|
|
|
603
808
|
"""Spawn a zombie according to the stage's interior/exterior mix."""
|
|
604
809
|
stage = game_data.stage
|
|
605
810
|
|
|
606
|
-
def
|
|
607
|
-
|
|
608
|
-
|
|
811
|
+
def _spawn_interior() -> bool:
|
|
812
|
+
return _spawn_nearby_zombie(game_data, config) is not None
|
|
813
|
+
|
|
814
|
+
def _spawn_exterior() -> bool:
|
|
609
815
|
return spawn_exterior_zombie(game_data, config) is not None
|
|
610
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
|
+
|
|
611
832
|
interior_weight = max(0.0, stage.interior_spawn_weight)
|
|
612
833
|
exterior_weight = max(0.0, stage.exterior_spawn_weight)
|
|
613
|
-
|
|
834
|
+
fall_weight = max(0.0, getattr(stage, "interior_fall_spawn_weight", 0.0))
|
|
835
|
+
total_weight = interior_weight + exterior_weight + fall_weight
|
|
614
836
|
if total_weight <= 0:
|
|
615
837
|
# Fall back to exterior spawns if weights are unset or invalid.
|
|
616
|
-
return
|
|
838
|
+
return _spawn_exterior()
|
|
617
839
|
|
|
618
840
|
pick = RNG.uniform(0, total_weight)
|
|
619
841
|
if pick <= interior_weight:
|
|
620
|
-
if
|
|
842
|
+
if _spawn_interior():
|
|
843
|
+
return True
|
|
844
|
+
fall_result = _spawn_fall()
|
|
845
|
+
if fall_result == "scheduled":
|
|
621
846
|
return True
|
|
622
|
-
return
|
|
623
|
-
if
|
|
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":
|
|
624
857
|
return True
|
|
625
|
-
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
|
|
@@ -84,6 +87,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
84
87
|
walkable_cells=[],
|
|
85
88
|
outer_wall_cells=set(),
|
|
86
89
|
wall_cells=set(),
|
|
90
|
+
fall_spawn_cells=set(),
|
|
87
91
|
bevel_corners={},
|
|
88
92
|
),
|
|
89
93
|
fog={
|
|
@@ -108,29 +112,26 @@ def carbonize_outdoor_zombies(game_data: GameData) -> None:
|
|
|
108
112
|
if not group:
|
|
109
113
|
return
|
|
110
114
|
for zombie in list(group):
|
|
111
|
-
|
|
112
|
-
if not alive():
|
|
115
|
+
if not zombie.alive():
|
|
113
116
|
continue
|
|
114
117
|
center = zombie.rect.center
|
|
115
118
|
if any(rect_obj.collidepoint(center) for rect_obj in outside_rects):
|
|
116
|
-
carbonize
|
|
117
|
-
if carbonize:
|
|
118
|
-
carbonize()
|
|
119
|
+
zombie.carbonize()
|
|
119
120
|
|
|
120
121
|
|
|
121
|
-
def
|
|
122
|
-
"""Advance the
|
|
122
|
+
def update_endurance_timer(game_data: GameData, dt_ms: int) -> None:
|
|
123
|
+
"""Advance the endurance countdown and trigger dawn handoff."""
|
|
123
124
|
stage = game_data.stage
|
|
124
125
|
state = game_data.state
|
|
125
|
-
if not stage.
|
|
126
|
+
if not stage.endurance_stage:
|
|
126
127
|
return
|
|
127
|
-
if state.
|
|
128
|
+
if state.endurance_goal_ms <= 0 or dt_ms <= 0:
|
|
128
129
|
return
|
|
129
|
-
state.
|
|
130
|
-
state.
|
|
131
|
-
state.
|
|
130
|
+
state.endurance_elapsed_ms = min(
|
|
131
|
+
state.endurance_goal_ms,
|
|
132
|
+
state.endurance_elapsed_ms + dt_ms,
|
|
132
133
|
)
|
|
133
|
-
if not state.dawn_ready and state.
|
|
134
|
+
if not state.dawn_ready and state.endurance_elapsed_ms >= state.endurance_goal_ms:
|
|
134
135
|
state.dawn_ready = True
|
|
135
136
|
state.dawn_prompt_at = pygame.time.get_ticks()
|
|
136
137
|
_set_ambient_palette(game_data, DAWN_AMBIENT_PALETTE_KEY, force=True)
|