zombie-escape 1.12.3__py3-none-any.whl → 1.13.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/__main__.py +7 -0
- zombie_escape/entities.py +554 -132
- zombie_escape/entities_constants.py +29 -12
- zombie_escape/export_images.py +296 -0
- zombie_escape/gameplay/__init__.py +2 -1
- zombie_escape/gameplay/constants.py +6 -0
- zombie_escape/gameplay/layout.py +85 -20
- zombie_escape/gameplay/movement.py +62 -4
- zombie_escape/gameplay/spawn.py +47 -40
- zombie_escape/gameplay/state.py +3 -0
- zombie_escape/gameplay/survivors.py +4 -1
- zombie_escape/level_blueprints.py +169 -15
- zombie_escape/locales/ui.en.json +12 -2
- zombie_escape/locales/ui.ja.json +12 -2
- zombie_escape/models.py +9 -3
- zombie_escape/render.py +98 -21
- zombie_escape/render_assets.py +94 -0
- zombie_escape/render_constants.py +16 -0
- zombie_escape/screens/game_over.py +11 -0
- zombie_escape/screens/gameplay.py +68 -14
- zombie_escape/screens/title.py +18 -7
- zombie_escape/stage_constants.py +46 -10
- zombie_escape/zombie_escape.py +23 -0
- {zombie_escape-1.12.3.dist-info → zombie_escape-1.13.1.dist-info}/METADATA +40 -15
- zombie_escape-1.13.1.dist-info/RECORD +49 -0
- zombie_escape-1.12.3.dist-info/RECORD +0 -47
- {zombie_escape-1.12.3.dist-info → zombie_escape-1.13.1.dist-info}/WHEEL +0 -0
- {zombie_escape-1.12.3.dist-info → zombie_escape-1.13.1.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.12.3.dist-info → zombie_escape-1.13.1.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -16,13 +16,13 @@ from ..entities_constants import (
|
|
|
16
16
|
HUMANOID_WALL_BUMP_FRAMES,
|
|
17
17
|
PLAYER_SPEED,
|
|
18
18
|
ZOMBIE_SEPARATION_DISTANCE,
|
|
19
|
-
|
|
19
|
+
ZOMBIE_WALL_HUG_SENSOR_DISTANCE,
|
|
20
20
|
)
|
|
21
21
|
from ..gameplay_constants import (
|
|
22
22
|
SHOES_SPEED_MULTIPLIER_ONE,
|
|
23
23
|
SHOES_SPEED_MULTIPLIER_TWO,
|
|
24
24
|
)
|
|
25
|
-
from ..models import GameData
|
|
25
|
+
from ..models import FallingZombie, GameData
|
|
26
26
|
from ..world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
|
|
27
27
|
from .constants import MAX_ZOMBIES
|
|
28
28
|
from .spawn import spawn_weighted_zombie, update_falling_zombies
|
|
@@ -84,6 +84,28 @@ def _shoes_speed_multiplier(shoes_count: int) -> float:
|
|
|
84
84
|
return 1.0
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def _handle_pitfall_detection(
|
|
88
|
+
x: float,
|
|
89
|
+
y: float,
|
|
90
|
+
cell_size: int,
|
|
91
|
+
pitfall_cells: set[tuple[int, int]],
|
|
92
|
+
pull_distance: float,
|
|
93
|
+
) -> tuple[int, int] | None:
|
|
94
|
+
"""Check if position is in pitfall and return pulled target coordinates if so."""
|
|
95
|
+
cx, cy = int(x // cell_size), int(y // cell_size)
|
|
96
|
+
if (cx, cy) not in pitfall_cells:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
cell_center_x = (cx * cell_size) + (cell_size // 2)
|
|
100
|
+
cell_center_y = (cy * cell_size) + (cell_size // 2)
|
|
101
|
+
dx, dy = cell_center_x - x, cell_center_y - y
|
|
102
|
+
dist = math.hypot(dx, dy)
|
|
103
|
+
if dist > 0:
|
|
104
|
+
move_factor = min(1.0, pull_distance / dist)
|
|
105
|
+
return int(x + dx * move_factor), int(y + dy * move_factor)
|
|
106
|
+
return int(x), int(y)
|
|
107
|
+
|
|
108
|
+
|
|
87
109
|
def update_entities(
|
|
88
110
|
game_data: GameData,
|
|
89
111
|
player_dx: float,
|
|
@@ -105,6 +127,8 @@ def update_entities(
|
|
|
105
127
|
stage = game_data.stage
|
|
106
128
|
active_car = car if car and car.alive() else None
|
|
107
129
|
wall_cells = game_data.layout.wall_cells
|
|
130
|
+
pitfall_cells = game_data.layout.pitfall_cells
|
|
131
|
+
walkable_cells = game_data.layout.walkable_cells
|
|
108
132
|
bevel_corners = game_data.layout.bevel_corners
|
|
109
133
|
|
|
110
134
|
all_walls = list(wall_group) if wall_index is None else None
|
|
@@ -135,7 +159,14 @@ def update_entities(
|
|
|
135
159
|
grid_rows=stage.grid_rows,
|
|
136
160
|
)
|
|
137
161
|
car_walls = _walls_near((active_car.x, active_car.y), 150.0)
|
|
138
|
-
active_car.move(
|
|
162
|
+
active_car.move(
|
|
163
|
+
car_dx,
|
|
164
|
+
car_dy,
|
|
165
|
+
car_walls,
|
|
166
|
+
walls_nearby=wall_index is not None,
|
|
167
|
+
cell_size=game_data.cell_size,
|
|
168
|
+
pitfall_cells=pitfall_cells,
|
|
169
|
+
)
|
|
139
170
|
player.rect.center = active_car.rect.center
|
|
140
171
|
player.x, player.y = active_car.x, active_car.y
|
|
141
172
|
elif not player.in_car:
|
|
@@ -161,6 +192,8 @@ def update_entities(
|
|
|
161
192
|
cell_size=game_data.cell_size,
|
|
162
193
|
level_width=game_data.level_width,
|
|
163
194
|
level_height=game_data.level_height,
|
|
195
|
+
pitfall_cells=pitfall_cells,
|
|
196
|
+
walkable_cells=walkable_cells,
|
|
164
197
|
)
|
|
165
198
|
else:
|
|
166
199
|
# Player flagged as in-car but car is gone; drop them back to foot control
|
|
@@ -276,7 +309,7 @@ def update_entities(
|
|
|
276
309
|
+ (pos[1] - zombie.y) ** 2,
|
|
277
310
|
)
|
|
278
311
|
nearby_candidates = _nearby_zombies(idx)
|
|
279
|
-
zombie_search_radius =
|
|
312
|
+
zombie_search_radius = ZOMBIE_WALL_HUG_SENSOR_DISTANCE + zombie.radius + 120
|
|
280
313
|
nearby_walls = _walls_near((zombie.x, zombie.y), zombie_search_radius)
|
|
281
314
|
zombie.update(
|
|
282
315
|
target,
|
|
@@ -290,5 +323,30 @@ def update_entities(
|
|
|
290
323
|
level_height=game_data.level_height,
|
|
291
324
|
outer_wall_cells=game_data.layout.outer_wall_cells,
|
|
292
325
|
wall_cells=game_data.layout.wall_cells,
|
|
326
|
+
pitfall_cells=game_data.layout.pitfall_cells,
|
|
293
327
|
bevel_corners=game_data.layout.bevel_corners,
|
|
294
328
|
)
|
|
329
|
+
|
|
330
|
+
# Check zombie pitfall
|
|
331
|
+
pull_dist = zombie.radius * 0.5
|
|
332
|
+
pitfall_target = _handle_pitfall_detection(
|
|
333
|
+
zombie.x,
|
|
334
|
+
zombie.y,
|
|
335
|
+
game_data.cell_size,
|
|
336
|
+
pitfall_cells,
|
|
337
|
+
pull_dist,
|
|
338
|
+
)
|
|
339
|
+
if pitfall_target is not None:
|
|
340
|
+
zombie.kill()
|
|
341
|
+
fall = FallingZombie(
|
|
342
|
+
start_pos=(int(zombie.x), int(zombie.y)),
|
|
343
|
+
target_pos=pitfall_target,
|
|
344
|
+
started_at_ms=pygame.time.get_ticks(),
|
|
345
|
+
pre_fx_ms=0,
|
|
346
|
+
fall_duration_ms=500,
|
|
347
|
+
dust_duration_ms=0,
|
|
348
|
+
tracker=zombie.tracker,
|
|
349
|
+
wall_hugging=zombie.wall_hugging,
|
|
350
|
+
mode="pitfall",
|
|
351
|
+
)
|
|
352
|
+
game_data.state.falling_zombies.append(fall)
|
zombie_escape/gameplay/spawn.py
CHANGED
|
@@ -35,6 +35,9 @@ from ..render_constants import (
|
|
|
35
35
|
)
|
|
36
36
|
from ..rng import get_rng
|
|
37
37
|
from .constants import (
|
|
38
|
+
FALLING_ZOMBIE_DUST_DURATION_MS,
|
|
39
|
+
FALLING_ZOMBIE_DURATION_MS,
|
|
40
|
+
FALLING_ZOMBIE_PRE_FX_MS,
|
|
38
41
|
MAX_ZOMBIES,
|
|
39
42
|
ZOMBIE_SPAWN_PLAYER_BUFFER,
|
|
40
43
|
ZOMBIE_TRACKER_AGING_DURATION_FRAMES,
|
|
@@ -82,22 +85,22 @@ def _car_appearance_for_stage(stage: Stage | None) -> str:
|
|
|
82
85
|
def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
|
|
83
86
|
normal_ratio = 1.0
|
|
84
87
|
tracker_ratio = 0.0
|
|
85
|
-
|
|
88
|
+
wall_hugging_ratio = 0.0
|
|
86
89
|
if stage is not None:
|
|
87
90
|
normal_ratio = max(0.0, min(1.0, stage.zombie_normal_ratio))
|
|
88
91
|
tracker_ratio = max(0.0, min(1.0, stage.zombie_tracker_ratio))
|
|
89
|
-
|
|
90
|
-
if normal_ratio + tracker_ratio +
|
|
92
|
+
wall_hugging_ratio = max(0.0, min(1.0, stage.zombie_wall_hugging_ratio))
|
|
93
|
+
if normal_ratio + tracker_ratio + wall_hugging_ratio <= 0:
|
|
91
94
|
normal_ratio = 1.0
|
|
92
95
|
tracker_ratio = 0.0
|
|
93
|
-
|
|
96
|
+
wall_hugging_ratio = 0.0
|
|
94
97
|
if (
|
|
95
98
|
normal_ratio == 1.0
|
|
96
|
-
and (tracker_ratio > 0.0 or
|
|
97
|
-
and tracker_ratio +
|
|
99
|
+
and (tracker_ratio > 0.0 or wall_hugging_ratio > 0.0)
|
|
100
|
+
and tracker_ratio + wall_hugging_ratio <= 1.0
|
|
98
101
|
):
|
|
99
|
-
normal_ratio = max(0.0, 1.0 - tracker_ratio -
|
|
100
|
-
total_ratio = normal_ratio + tracker_ratio +
|
|
102
|
+
normal_ratio = max(0.0, 1.0 - tracker_ratio - wall_hugging_ratio)
|
|
103
|
+
total_ratio = normal_ratio + tracker_ratio + wall_hugging_ratio
|
|
101
104
|
if total_ratio <= 0:
|
|
102
105
|
return False, False
|
|
103
106
|
pick = RNG.random() * total_ratio
|
|
@@ -210,7 +213,7 @@ def _schedule_falling_zombie(
|
|
|
210
213
|
if len(zombie_group) + len(state.falling_zombies) >= MAX_ZOMBIES:
|
|
211
214
|
return "blocked"
|
|
212
215
|
min_distance = game_data.stage.tile_size * 0.5
|
|
213
|
-
tracker,
|
|
216
|
+
tracker, wall_hugging = _pick_zombie_variant(game_data.stage)
|
|
214
217
|
|
|
215
218
|
def _candidate_clear(pos: tuple[int, int]) -> bool:
|
|
216
219
|
candidate = _create_zombie(
|
|
@@ -218,7 +221,7 @@ def _schedule_falling_zombie(
|
|
|
218
221
|
start_pos=pos,
|
|
219
222
|
stage=game_data.stage,
|
|
220
223
|
tracker=tracker,
|
|
221
|
-
|
|
224
|
+
wall_hugging=wall_hugging,
|
|
222
225
|
)
|
|
223
226
|
return _is_spawn_position_clear(game_data, candidate)
|
|
224
227
|
|
|
@@ -231,17 +234,17 @@ def _schedule_falling_zombie(
|
|
|
231
234
|
if allow_carry:
|
|
232
235
|
state.falling_spawn_carry += 1
|
|
233
236
|
return "no_position"
|
|
234
|
-
start_offset
|
|
235
|
-
start_pos = (int(spawn_pos[0]), int(spawn_pos[1]
|
|
237
|
+
# start_offset removed; animation handles "falling" via scaling now.
|
|
238
|
+
start_pos = (int(spawn_pos[0]), int(spawn_pos[1]))
|
|
236
239
|
fall = FallingZombie(
|
|
237
240
|
start_pos=start_pos,
|
|
238
241
|
target_pos=(int(spawn_pos[0]), int(spawn_pos[1])),
|
|
239
242
|
started_at_ms=pygame.time.get_ticks(),
|
|
240
|
-
pre_fx_ms=
|
|
241
|
-
fall_duration_ms=
|
|
242
|
-
dust_duration_ms=
|
|
243
|
+
pre_fx_ms=FALLING_ZOMBIE_PRE_FX_MS,
|
|
244
|
+
fall_duration_ms=FALLING_ZOMBIE_DURATION_MS,
|
|
245
|
+
dust_duration_ms=FALLING_ZOMBIE_DUST_DURATION_MS,
|
|
243
246
|
tracker=tracker,
|
|
244
|
-
|
|
247
|
+
wall_hugging=wall_hugging,
|
|
245
248
|
)
|
|
246
249
|
state.falling_zombies.append(fall)
|
|
247
250
|
return "scheduled"
|
|
@@ -254,7 +257,7 @@ def _create_zombie(
|
|
|
254
257
|
hint_pos: tuple[float, float] | None = None,
|
|
255
258
|
stage: Stage | None = None,
|
|
256
259
|
tracker: bool | None = None,
|
|
257
|
-
|
|
260
|
+
wall_hugging: bool | None = None,
|
|
258
261
|
) -> Zombie:
|
|
259
262
|
"""Factory to create zombies with optional fast variants."""
|
|
260
263
|
fast_conf = config.get("fast_zombies", {})
|
|
@@ -271,14 +274,14 @@ def _create_zombie(
|
|
|
271
274
|
)
|
|
272
275
|
else:
|
|
273
276
|
aging_duration_frames = ZOMBIE_AGING_DURATION_FRAMES
|
|
274
|
-
if tracker is None or
|
|
275
|
-
picked_tracker,
|
|
277
|
+
if tracker is None or wall_hugging is None:
|
|
278
|
+
picked_tracker, picked_wall_hugging = _pick_zombie_variant(stage)
|
|
276
279
|
if tracker is None:
|
|
277
280
|
tracker = picked_tracker
|
|
278
|
-
if
|
|
279
|
-
|
|
281
|
+
if wall_hugging is None:
|
|
282
|
+
wall_hugging = picked_wall_hugging
|
|
280
283
|
if tracker:
|
|
281
|
-
|
|
284
|
+
wall_hugging = False
|
|
282
285
|
if tracker:
|
|
283
286
|
ratio = (
|
|
284
287
|
ZOMBIE_TRACKER_AGING_DURATION_FRAMES / ZOMBIE_AGING_DURATION_FRAMES
|
|
@@ -312,7 +315,7 @@ def _create_zombie(
|
|
|
312
315
|
y=float(start_pos[1]),
|
|
313
316
|
speed=base_speed,
|
|
314
317
|
tracker=tracker,
|
|
315
|
-
|
|
318
|
+
wall_hugging=wall_hugging,
|
|
316
319
|
aging_duration_frames=aging_duration_frames,
|
|
317
320
|
)
|
|
318
321
|
|
|
@@ -656,7 +659,8 @@ def setup_player_and_cars(
|
|
|
656
659
|
player_pos = _pick_center(layout_data["player_cells"] or walkable_cells)
|
|
657
660
|
player = Player(*player_pos)
|
|
658
661
|
|
|
659
|
-
|
|
662
|
+
car_walkable = layout_data.get("car_walkable_cells") or walkable_cells
|
|
663
|
+
car_candidates = list(layout_data["car_cells"] or car_walkable)
|
|
660
664
|
waiting_cars: list[Car] = []
|
|
661
665
|
car_appearance = _car_appearance_for_stage(game_data.stage)
|
|
662
666
|
|
|
@@ -722,7 +726,7 @@ def spawn_initial_zombies(
|
|
|
722
726
|
start_pos=candidate_center,
|
|
723
727
|
stage=game_data.stage,
|
|
724
728
|
tracker=True,
|
|
725
|
-
|
|
729
|
+
wall_hugging=False,
|
|
726
730
|
)
|
|
727
731
|
if not spritecollideany_walls(tentative, wall_group):
|
|
728
732
|
zombie_group.add(tentative)
|
|
@@ -741,13 +745,13 @@ def spawn_initial_zombies(
|
|
|
741
745
|
)
|
|
742
746
|
|
|
743
747
|
for pos in positions:
|
|
744
|
-
tracker,
|
|
748
|
+
tracker, wall_hugging = _pick_zombie_variant(game_data.stage)
|
|
745
749
|
tentative = _create_zombie(
|
|
746
750
|
config,
|
|
747
751
|
start_pos=pos,
|
|
748
752
|
stage=game_data.stage,
|
|
749
753
|
tracker=tracker,
|
|
750
|
-
|
|
754
|
+
wall_hugging=wall_hugging,
|
|
751
755
|
)
|
|
752
756
|
if spritecollideany_walls(tentative, wall_group):
|
|
753
757
|
continue
|
|
@@ -763,7 +767,9 @@ def spawn_waiting_car(game_data: GameData) -> Car | None:
|
|
|
763
767
|
player = game_data.player
|
|
764
768
|
if not player:
|
|
765
769
|
return None
|
|
766
|
-
|
|
770
|
+
# Use cells that are 4-way reachable by car
|
|
771
|
+
car_walkable = list(game_data.layout.car_walkable_cells)
|
|
772
|
+
walkable_cells = car_walkable if car_walkable else game_data.layout.walkable_cells
|
|
767
773
|
if not walkable_cells:
|
|
768
774
|
return None
|
|
769
775
|
wall_group = game_data.groups.wall_group
|
|
@@ -919,18 +925,19 @@ def update_falling_zombies(game_data: GameData, config: dict[str, Any]) -> None:
|
|
|
919
925
|
fall.dust_started = True
|
|
920
926
|
if now < spawn_at:
|
|
921
927
|
continue
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
928
|
+
|
|
929
|
+
if getattr(fall, "mode", "spawn") == "spawn":
|
|
930
|
+
if len(zombie_group) < MAX_ZOMBIES:
|
|
931
|
+
candidate = _create_zombie(
|
|
932
|
+
config,
|
|
933
|
+
start_pos=fall.target_pos,
|
|
934
|
+
stage=game_data.stage,
|
|
935
|
+
tracker=fall.tracker,
|
|
936
|
+
wall_hugging=fall.wall_hugging,
|
|
937
|
+
)
|
|
938
|
+
zombie_group.add(candidate)
|
|
939
|
+
all_sprites.add(candidate, layer=1)
|
|
940
|
+
|
|
934
941
|
state.falling_zombies.remove(fall)
|
|
935
942
|
|
|
936
943
|
|
zombie_escape/gameplay/state.py
CHANGED
|
@@ -51,6 +51,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
51
51
|
last_zombie_spawn_time=0,
|
|
52
52
|
dawn_carbonized=False,
|
|
53
53
|
debug_mode=False,
|
|
54
|
+
show_fps=False,
|
|
54
55
|
falling_zombies=[],
|
|
55
56
|
falling_spawn_carry=0,
|
|
56
57
|
dust_rings=[],
|
|
@@ -88,6 +89,8 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
88
89
|
walkable_cells=[],
|
|
89
90
|
outer_wall_cells=set(),
|
|
90
91
|
wall_cells=set(),
|
|
92
|
+
pitfall_cells=set(),
|
|
93
|
+
car_walkable_cells=set(),
|
|
91
94
|
fall_spawn_cells=set(),
|
|
92
95
|
bevel_corners={},
|
|
93
96
|
),
|
|
@@ -44,6 +44,7 @@ def update_survivors(
|
|
|
44
44
|
target_rect = car.rect if player.in_car and car and car.alive() else player.rect
|
|
45
45
|
target_pos = target_rect.center
|
|
46
46
|
survivors = [s for s in survivor_group if s.alive()]
|
|
47
|
+
|
|
47
48
|
for survivor in survivors:
|
|
48
49
|
survivor.update_behavior(
|
|
49
50
|
target_pos,
|
|
@@ -51,6 +52,8 @@ def update_survivors(
|
|
|
51
52
|
wall_index=wall_index,
|
|
52
53
|
cell_size=game_data.cell_size,
|
|
53
54
|
wall_cells=game_data.layout.wall_cells,
|
|
55
|
+
pitfall_cells=game_data.layout.pitfall_cells,
|
|
56
|
+
walkable_cells=game_data.layout.walkable_cells,
|
|
54
57
|
bevel_corners=game_data.layout.bevel_corners,
|
|
55
58
|
grid_cols=game_data.stage.grid_cols,
|
|
56
59
|
grid_rows=game_data.stage.grid_rows,
|
|
@@ -294,7 +297,7 @@ def handle_survivor_zombie_collisions(
|
|
|
294
297
|
start_pos=survivor.rect.center,
|
|
295
298
|
stage=game_data.stage,
|
|
296
299
|
tracker=collided_zombie.tracker,
|
|
297
|
-
|
|
300
|
+
wall_hugging=collided_zombie.wall_hugging,
|
|
298
301
|
)
|
|
299
302
|
zombie_group.add(new_zombie)
|
|
300
303
|
game_data.groups.all_sprites.add(new_zombie, layer=1)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Blueprint generator for randomized layouts.
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections import deque
|
|
4
|
+
|
|
5
|
+
from .rng import get_rng, seed_rng
|
|
4
6
|
|
|
5
7
|
EXITS_PER_SIDE = 1 # currently fixed to 1 per side (can be tuned)
|
|
6
8
|
NUM_WALL_LINES = 80 # reduced density (roughly 1/5 of previous 450)
|
|
@@ -13,15 +15,94 @@ SPAWN_ZOMBIES = 3
|
|
|
13
15
|
RNG = get_rng()
|
|
14
16
|
STEEL_BEAM_CHANCE = 0.02
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
|
|
19
|
+
class MapGenerationError(Exception):
|
|
20
|
+
"""Raised when a valid map cannot be generated after several attempts."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_car_connectivity(grid: list[str]) -> set[tuple[int, int]] | None:
|
|
24
|
+
"""Check if the Car can reach at least one exit (4-way BFS).
|
|
25
|
+
Returns the set of reachable tiles if valid, otherwise None.
|
|
26
|
+
"""
|
|
27
|
+
rows = len(grid)
|
|
28
|
+
cols = len(grid[0])
|
|
29
|
+
|
|
30
|
+
start_pos = None
|
|
31
|
+
passable_tiles = set()
|
|
32
|
+
exit_tiles = set()
|
|
33
|
+
|
|
34
|
+
for y in range(rows):
|
|
35
|
+
for x in range(cols):
|
|
36
|
+
ch = grid[y][x]
|
|
37
|
+
if ch == "C":
|
|
38
|
+
start_pos = (x, y)
|
|
39
|
+
if ch not in ("x", "B"):
|
|
40
|
+
passable_tiles.add((x, y))
|
|
41
|
+
if ch == "E":
|
|
42
|
+
exit_tiles.add((x, y))
|
|
43
|
+
|
|
44
|
+
if start_pos is None:
|
|
45
|
+
# If no car candidate, we can't validate car pathing.
|
|
46
|
+
return passable_tiles
|
|
47
|
+
|
|
48
|
+
reachable = {start_pos}
|
|
49
|
+
queue = deque([start_pos])
|
|
50
|
+
while queue:
|
|
51
|
+
x, y = queue.popleft()
|
|
52
|
+
for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
|
|
53
|
+
nx, ny = x + dx, y + dy
|
|
54
|
+
if (nx, ny) in passable_tiles and (nx, ny) not in reachable:
|
|
55
|
+
reachable.add((nx, ny))
|
|
56
|
+
queue.append((nx, ny))
|
|
57
|
+
|
|
58
|
+
# Car must reach at least one exit
|
|
59
|
+
if exit_tiles and not any(e in reachable for e in exit_tiles):
|
|
60
|
+
return None
|
|
61
|
+
return reachable
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_humanoid_connectivity(grid: list[str]) -> bool:
|
|
65
|
+
"""Check if all floor tiles are reachable by Humans (8-way BFS with jumps)."""
|
|
66
|
+
rows = len(grid)
|
|
67
|
+
cols = len(grid[0])
|
|
68
|
+
|
|
69
|
+
start_pos = None
|
|
70
|
+
passable_tiles = set()
|
|
71
|
+
|
|
72
|
+
for y in range(rows):
|
|
73
|
+
for x in range(cols):
|
|
74
|
+
ch = grid[y][x]
|
|
75
|
+
if ch == "P":
|
|
76
|
+
start_pos = (x, y)
|
|
77
|
+
if ch not in ("x", "B"):
|
|
78
|
+
passable_tiles.add((x, y))
|
|
79
|
+
|
|
80
|
+
if start_pos is None:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
reachable = {start_pos}
|
|
84
|
+
queue = deque([start_pos])
|
|
85
|
+
while queue:
|
|
86
|
+
x, y = queue.popleft()
|
|
87
|
+
for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (1, -1), (-1, 1), (-1, -1)):
|
|
88
|
+
nx, ny = x + dx, y + dy
|
|
89
|
+
if (nx, ny) in passable_tiles and (nx, ny) not in reachable:
|
|
90
|
+
reachable.add((nx, ny))
|
|
91
|
+
queue.append((nx, ny))
|
|
92
|
+
|
|
93
|
+
return len(passable_tiles) == len(reachable)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_connectivity(grid: list[str]) -> set[tuple[int, int]] | None:
|
|
97
|
+
"""Validate both car and humanoid movement conditions.
|
|
98
|
+
Returns car reachable tiles if both pass, otherwise None.
|
|
99
|
+
"""
|
|
100
|
+
car_reachable = validate_car_connectivity(grid)
|
|
101
|
+
if car_reachable is None:
|
|
102
|
+
return None
|
|
103
|
+
if not validate_humanoid_connectivity(grid):
|
|
104
|
+
return None
|
|
105
|
+
return car_reachable
|
|
25
106
|
|
|
26
107
|
|
|
27
108
|
def _collect_exit_adjacent_cells(grid: list[list[str]]) -> set[tuple[int, int]]:
|
|
@@ -311,6 +392,30 @@ def _place_steel_beams(
|
|
|
311
392
|
return beams
|
|
312
393
|
|
|
313
394
|
|
|
395
|
+
def _place_pitfalls(
|
|
396
|
+
grid: list[list[str]],
|
|
397
|
+
*,
|
|
398
|
+
density: float,
|
|
399
|
+
forbidden_cells: set[tuple[int, int]] | None = None,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Replace empty floor tiles with pitfalls based on density."""
|
|
402
|
+
if density <= 0.0:
|
|
403
|
+
return
|
|
404
|
+
cols, rows = len(grid[0]), len(grid)
|
|
405
|
+
forbidden = _collect_exit_adjacent_cells(grid)
|
|
406
|
+
if forbidden_cells:
|
|
407
|
+
forbidden |= forbidden_cells
|
|
408
|
+
|
|
409
|
+
for y in range(1, rows - 1):
|
|
410
|
+
for x in range(1, cols - 1):
|
|
411
|
+
if (x, y) in forbidden:
|
|
412
|
+
continue
|
|
413
|
+
if grid[y][x] != ".":
|
|
414
|
+
continue
|
|
415
|
+
if RNG.random() < density:
|
|
416
|
+
grid[y][x] = "x"
|
|
417
|
+
|
|
418
|
+
|
|
314
419
|
def _pick_empty_cell(
|
|
315
420
|
grid: list[list[str]],
|
|
316
421
|
margin: int,
|
|
@@ -332,7 +437,12 @@ def _pick_empty_cell(
|
|
|
332
437
|
|
|
333
438
|
|
|
334
439
|
def _generate_random_blueprint(
|
|
335
|
-
steel_chance: float,
|
|
440
|
+
steel_chance: float,
|
|
441
|
+
*,
|
|
442
|
+
cols: int,
|
|
443
|
+
rows: int,
|
|
444
|
+
wall_algo: str = "default",
|
|
445
|
+
pitfall_density: float = 0.0,
|
|
336
446
|
) -> dict:
|
|
337
447
|
grid = _init_grid(cols, rows)
|
|
338
448
|
_place_exits(grid, EXITS_PER_SIDE)
|
|
@@ -350,6 +460,21 @@ def _generate_random_blueprint(
|
|
|
350
460
|
grid[zy][zx] = "Z"
|
|
351
461
|
reserved_cells.add((zx, zy))
|
|
352
462
|
|
|
463
|
+
# Items
|
|
464
|
+
fx, fy = _pick_empty_cell(grid, SPAWN_MARGIN)
|
|
465
|
+
grid[fy][fx] = "f"
|
|
466
|
+
reserved_cells.add((fx, fy))
|
|
467
|
+
|
|
468
|
+
for _ in range(2):
|
|
469
|
+
lx, ly = _pick_empty_cell(grid, SPAWN_MARGIN)
|
|
470
|
+
grid[ly][lx] = "l"
|
|
471
|
+
reserved_cells.add((lx, ly))
|
|
472
|
+
|
|
473
|
+
for _ in range(2):
|
|
474
|
+
sx, sy = _pick_empty_cell(grid, SPAWN_MARGIN)
|
|
475
|
+
grid[sy][sx] = "s"
|
|
476
|
+
reserved_cells.add((sx, sy))
|
|
477
|
+
|
|
353
478
|
# Select and run the wall placement algorithm (after reserving spawns)
|
|
354
479
|
sparse_density = SPARSE_WALL_DENSITY
|
|
355
480
|
original_wall_algo = wall_algo
|
|
@@ -407,6 +532,13 @@ def _generate_random_blueprint(
|
|
|
407
532
|
)
|
|
408
533
|
wall_algo = "default"
|
|
409
534
|
|
|
535
|
+
# Place pitfalls BEFORE walls so walls avoid them (consistent with spawn reservation)
|
|
536
|
+
_place_pitfalls(
|
|
537
|
+
grid,
|
|
538
|
+
density=pitfall_density,
|
|
539
|
+
forbidden_cells=reserved_cells,
|
|
540
|
+
)
|
|
541
|
+
|
|
410
542
|
algo_func = WALL_ALGORITHMS[wall_algo]
|
|
411
543
|
if wall_algo in {"sparse_moore", "sparse_ortho"}:
|
|
412
544
|
algo_func(grid, density=sparse_density, forbidden_cells=reserved_cells)
|
|
@@ -422,7 +554,13 @@ def _generate_random_blueprint(
|
|
|
422
554
|
|
|
423
555
|
|
|
424
556
|
def choose_blueprint(
|
|
425
|
-
config: dict,
|
|
557
|
+
config: dict,
|
|
558
|
+
*,
|
|
559
|
+
cols: int,
|
|
560
|
+
rows: int,
|
|
561
|
+
wall_algo: str = "default",
|
|
562
|
+
pitfall_density: float = 0.0,
|
|
563
|
+
base_seed: int | None = None,
|
|
426
564
|
) -> dict:
|
|
427
565
|
# Currently only random generation; hook for future variants.
|
|
428
566
|
steel_conf = config.get("steel_beams", {})
|
|
@@ -430,6 +568,22 @@ def choose_blueprint(
|
|
|
430
568
|
steel_chance = float(steel_conf.get("chance", STEEL_BEAM_CHANCE))
|
|
431
569
|
except (TypeError, ValueError):
|
|
432
570
|
steel_chance = STEEL_BEAM_CHANCE
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
571
|
+
|
|
572
|
+
for attempt in range(20):
|
|
573
|
+
if base_seed is not None:
|
|
574
|
+
seed_rng(base_seed + attempt)
|
|
575
|
+
|
|
576
|
+
blueprint = _generate_random_blueprint(
|
|
577
|
+
steel_chance=steel_chance,
|
|
578
|
+
cols=cols,
|
|
579
|
+
rows=rows,
|
|
580
|
+
wall_algo=wall_algo,
|
|
581
|
+
pitfall_density=pitfall_density,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
car_reachable = validate_connectivity(blueprint["grid"])
|
|
585
|
+
if car_reachable is not None:
|
|
586
|
+
blueprint["car_reachable_cells"] = car_reachable
|
|
587
|
+
return blueprint
|
|
588
|
+
|
|
589
|
+
raise MapGenerationError("Connectivity validation failed after 20 attempts")
|
zombie_escape/locales/ui.en.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"settings": "Settings",
|
|
17
17
|
"quit": "Quit",
|
|
18
18
|
"locked_suffix": "[Locked]",
|
|
19
|
-
"window_hint": "Resize window: [ to shrink, ] to enlarge.\nF: toggle fullscreen
|
|
19
|
+
"window_hint": "Resize window: [ to shrink, ] to enlarge.\nF: toggle fullscreen.",
|
|
20
20
|
"seed_label": "Seed: %{value}",
|
|
21
21
|
"seed_hint": "Type 0-9 to set a custom seed, Backspace clears",
|
|
22
22
|
"seed_empty": "(auto)",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"resources": "Resources"
|
|
26
26
|
},
|
|
27
27
|
"readme": "Open README",
|
|
28
|
+
"readme_stage6": "Open Stage 6+ Guide",
|
|
28
29
|
"hints": {
|
|
29
30
|
"navigate": "Up/Down: choose an option",
|
|
30
31
|
"page_switch": "Left/Right: switch stage groups",
|
|
@@ -33,7 +34,8 @@
|
|
|
33
34
|
"option_help": {
|
|
34
35
|
"settings": "Open Settings to adjust assists and localization.",
|
|
35
36
|
"quit": "Close the game.",
|
|
36
|
-
"readme": "Open the GitHub README in your browser."
|
|
37
|
+
"readme": "Open the GitHub README in your browser.",
|
|
38
|
+
"readme_stage6": "Open the Stage 6+ guide in your browser."
|
|
37
39
|
}
|
|
38
40
|
},
|
|
39
41
|
"stages": {
|
|
@@ -127,6 +129,10 @@
|
|
|
127
129
|
"stage15": {
|
|
128
130
|
"name": "#15 The Divide",
|
|
129
131
|
"description": "A central hazard splits the building. Cross with care."
|
|
132
|
+
},
|
|
133
|
+
"stage16": {
|
|
134
|
+
"name": "#16 Floor Collapse",
|
|
135
|
+
"description": "Circumnavigate the collapsed floor panels and lure the zombies into the abyss."
|
|
130
136
|
}
|
|
131
137
|
},
|
|
132
138
|
"status": {
|
|
@@ -198,10 +204,14 @@
|
|
|
198
204
|
"game_over": {
|
|
199
205
|
"win": "YOU ESCAPED!",
|
|
200
206
|
"lose": "GAME OVER",
|
|
207
|
+
"fell_into_abyss": "You fell into the abyss...",
|
|
201
208
|
"prompt": "ESC/SPACE/Select/South: Title · R: Retry",
|
|
202
209
|
"scream": "AAAAHHH!!",
|
|
203
210
|
"survivors_summary": "Evacuated: %{count}",
|
|
204
211
|
"endurance_duration": "Time survived %{time}"
|
|
212
|
+
},
|
|
213
|
+
"errors": {
|
|
214
|
+
"map_generation_failed": "Map generation failed. Please try a different seed or stage."
|
|
205
215
|
}
|
|
206
216
|
}
|
|
207
217
|
}
|