zombie-escape 1.12.0__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/colors.py +22 -14
- zombie_escape/entities.py +756 -147
- zombie_escape/entities_constants.py +35 -14
- zombie_escape/export_images.py +296 -0
- zombie_escape/gameplay/__init__.py +2 -1
- zombie_escape/gameplay/constants.py +6 -0
- zombie_escape/gameplay/footprints.py +4 -0
- zombie_escape/gameplay/interactions.py +19 -7
- zombie_escape/gameplay/layout.py +103 -34
- zombie_escape/gameplay/movement.py +85 -5
- zombie_escape/gameplay/spawn.py +139 -90
- zombie_escape/gameplay/state.py +18 -9
- zombie_escape/gameplay/survivors.py +13 -2
- zombie_escape/gameplay/utils.py +40 -21
- zombie_escape/level_blueprints.py +256 -19
- zombie_escape/locales/ui.en.json +12 -2
- zombie_escape/locales/ui.ja.json +12 -2
- zombie_escape/models.py +14 -7
- zombie_escape/render.py +149 -37
- zombie_escape/render_assets.py +419 -124
- zombie_escape/render_constants.py +27 -0
- zombie_escape/screens/game_over.py +14 -3
- zombie_escape/screens/gameplay.py +72 -14
- zombie_escape/screens/title.py +18 -7
- zombie_escape/stage_constants.py +51 -15
- zombie_escape/zombie_escape.py +24 -1
- {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/METADATA +41 -15
- zombie_escape-1.13.1.dist-info/RECORD +49 -0
- zombie_escape-1.12.0.dist-info/RECORD +0 -47
- {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/WHEEL +0 -0
- {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.12.0.dist-info → zombie_escape-1.13.1.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -29,7 +29,9 @@ RNG = get_rng()
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def update_survivors(
|
|
32
|
-
game_data: GameData,
|
|
32
|
+
game_data: GameData,
|
|
33
|
+
wall_index: WallIndex | None = None,
|
|
34
|
+
wall_target_cell: tuple[int, int] | None = None,
|
|
33
35
|
) -> None:
|
|
34
36
|
if not (game_data.stage.rescue_stage or game_data.stage.buddy_required_count > 0):
|
|
35
37
|
return
|
|
@@ -42,6 +44,7 @@ def update_survivors(
|
|
|
42
44
|
target_rect = car.rect if player.in_car and car and car.alive() else player.rect
|
|
43
45
|
target_pos = target_rect.center
|
|
44
46
|
survivors = [s for s in survivor_group if s.alive()]
|
|
47
|
+
|
|
45
48
|
for survivor in survivors:
|
|
46
49
|
survivor.update_behavior(
|
|
47
50
|
target_pos,
|
|
@@ -49,11 +52,14 @@ def update_survivors(
|
|
|
49
52
|
wall_index=wall_index,
|
|
50
53
|
cell_size=game_data.cell_size,
|
|
51
54
|
wall_cells=game_data.layout.wall_cells,
|
|
55
|
+
pitfall_cells=game_data.layout.pitfall_cells,
|
|
56
|
+
walkable_cells=game_data.layout.walkable_cells,
|
|
52
57
|
bevel_corners=game_data.layout.bevel_corners,
|
|
53
58
|
grid_cols=game_data.stage.grid_cols,
|
|
54
59
|
grid_rows=game_data.stage.grid_rows,
|
|
55
60
|
level_width=game_data.level_width,
|
|
56
61
|
level_height=game_data.level_height,
|
|
62
|
+
wall_target_cell=wall_target_cell,
|
|
57
63
|
)
|
|
58
64
|
|
|
59
65
|
# Gently prevent survivors from overlapping the player or each other
|
|
@@ -106,6 +112,7 @@ def update_survivors(
|
|
|
106
112
|
other.rect.center = (int(other.x), int(other.y))
|
|
107
113
|
|
|
108
114
|
|
|
115
|
+
|
|
109
116
|
def calculate_car_speed_for_passengers(
|
|
110
117
|
passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS
|
|
111
118
|
) -> float:
|
|
@@ -243,6 +250,7 @@ def handle_survivor_zombie_collisions(
|
|
|
243
250
|
zombie_xs = [z.rect.centerx for z in zombies]
|
|
244
251
|
camera = game_data.camera
|
|
245
252
|
walkable_cells = game_data.layout.walkable_cells
|
|
253
|
+
cell_size = game_data.cell_size
|
|
246
254
|
|
|
247
255
|
for survivor in list(survivor_group):
|
|
248
256
|
if not survivor.alive():
|
|
@@ -275,6 +283,7 @@ def handle_survivor_zombie_collisions(
|
|
|
275
283
|
if not rect_visible_on_screen(camera, survivor.rect):
|
|
276
284
|
spawn_pos = find_nearby_offscreen_spawn_position(
|
|
277
285
|
walkable_cells,
|
|
286
|
+
cell_size,
|
|
278
287
|
camera=camera,
|
|
279
288
|
)
|
|
280
289
|
survivor.teleport(spawn_pos)
|
|
@@ -288,7 +297,7 @@ def handle_survivor_zombie_collisions(
|
|
|
288
297
|
start_pos=survivor.rect.center,
|
|
289
298
|
stage=game_data.stage,
|
|
290
299
|
tracker=collided_zombie.tracker,
|
|
291
|
-
|
|
300
|
+
wall_hugging=collided_zombie.wall_hugging,
|
|
292
301
|
)
|
|
293
302
|
zombie_group.add(new_zombie)
|
|
294
303
|
game_data.groups.all_sprites.add(new_zombie, layer=1)
|
|
@@ -310,6 +319,7 @@ def respawn_buddies_near_player(game_data: GameData) -> None:
|
|
|
310
319
|
wall_group = game_data.groups.wall_group
|
|
311
320
|
camera = game_data.camera
|
|
312
321
|
walkable_cells = game_data.layout.walkable_cells
|
|
322
|
+
cell_size = game_data.cell_size
|
|
313
323
|
offsets = [
|
|
314
324
|
(BUDDY_RADIUS * 3, 0),
|
|
315
325
|
(-BUDDY_RADIUS * 3, 0),
|
|
@@ -321,6 +331,7 @@ def respawn_buddies_near_player(game_data: GameData) -> None:
|
|
|
321
331
|
if walkable_cells:
|
|
322
332
|
spawn_pos = find_nearby_offscreen_spawn_position(
|
|
323
333
|
walkable_cells,
|
|
334
|
+
cell_size,
|
|
324
335
|
camera=camera,
|
|
325
336
|
)
|
|
326
337
|
else:
|
zombie_escape/gameplay/utils.py
CHANGED
|
@@ -25,7 +25,8 @@ def rect_visible_on_screen(camera: Camera | None, rect: pygame.Rect) -> bool:
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def _scatter_positions_on_walkable(
|
|
28
|
-
walkable_cells: list[
|
|
28
|
+
walkable_cells: list[tuple[int, int]],
|
|
29
|
+
cell_size: int,
|
|
29
30
|
spawn_rate: float,
|
|
30
31
|
*,
|
|
31
32
|
jitter_ratio: float = 0.35,
|
|
@@ -35,17 +36,21 @@ def _scatter_positions_on_walkable(
|
|
|
35
36
|
return positions
|
|
36
37
|
|
|
37
38
|
clamped_rate = max(0.0, min(1.0, spawn_rate))
|
|
38
|
-
for
|
|
39
|
+
for cell_x, cell_y in walkable_cells:
|
|
39
40
|
if RNG.random() >= clamped_rate:
|
|
40
41
|
continue
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
jitter_extent = cell_size * jitter_ratio
|
|
43
|
+
jitter_x = RNG.uniform(-jitter_extent, jitter_extent)
|
|
44
|
+
jitter_y = RNG.uniform(-jitter_extent, jitter_extent)
|
|
45
|
+
base_x = (cell_x * cell_size) + (cell_size / 2)
|
|
46
|
+
base_y = (cell_y * cell_size) + (cell_size / 2)
|
|
47
|
+
positions.append((int(base_x + jitter_x), int(base_y + jitter_y)))
|
|
44
48
|
return positions
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
def find_interior_spawn_positions(
|
|
48
|
-
walkable_cells: list[
|
|
52
|
+
walkable_cells: list[tuple[int, int]],
|
|
53
|
+
cell_size: int,
|
|
49
54
|
spawn_rate: float,
|
|
50
55
|
*,
|
|
51
56
|
player: Player | None = None,
|
|
@@ -53,12 +58,14 @@ def find_interior_spawn_positions(
|
|
|
53
58
|
) -> list[tuple[int, int]]:
|
|
54
59
|
positions = _scatter_positions_on_walkable(
|
|
55
60
|
walkable_cells,
|
|
61
|
+
cell_size,
|
|
56
62
|
spawn_rate,
|
|
57
63
|
jitter_ratio=0.35,
|
|
58
64
|
)
|
|
59
65
|
if not positions and spawn_rate > 0:
|
|
60
66
|
positions = _scatter_positions_on_walkable(
|
|
61
67
|
walkable_cells,
|
|
68
|
+
cell_size,
|
|
62
69
|
spawn_rate * 1.5,
|
|
63
70
|
jitter_ratio=0.35,
|
|
64
71
|
)
|
|
@@ -78,7 +85,8 @@ def find_interior_spawn_positions(
|
|
|
78
85
|
|
|
79
86
|
|
|
80
87
|
def find_nearby_offscreen_spawn_position(
|
|
81
|
-
walkable_cells: list[
|
|
88
|
+
walkable_cells: list[tuple[int, int]],
|
|
89
|
+
cell_size: int,
|
|
82
90
|
*,
|
|
83
91
|
player: Player | None = None,
|
|
84
92
|
camera: Camera | None = None,
|
|
@@ -104,10 +112,14 @@ def find_nearby_offscreen_spawn_position(
|
|
|
104
112
|
None if max_player_dist is None else max_player_dist * max_player_dist
|
|
105
113
|
)
|
|
106
114
|
for _ in range(max(1, attempts)):
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
cell_x, cell_y = RNG.choice(walkable_cells)
|
|
116
|
+
jitter_extent = cell_size * 0.35
|
|
117
|
+
jitter_x = RNG.uniform(-jitter_extent, jitter_extent)
|
|
118
|
+
jitter_y = RNG.uniform(-jitter_extent, jitter_extent)
|
|
119
|
+
candidate = (
|
|
120
|
+
int((cell_x * cell_size) + (cell_size / 2) + jitter_x),
|
|
121
|
+
int((cell_y * cell_size) + (cell_size / 2) + jitter_y),
|
|
122
|
+
)
|
|
111
123
|
if player is not None and (
|
|
112
124
|
min_distance_sq is not None or max_distance_sq is not None
|
|
113
125
|
):
|
|
@@ -123,8 +135,11 @@ def find_nearby_offscreen_spawn_position(
|
|
|
123
135
|
return candidate
|
|
124
136
|
if player is not None and (min_distance_sq is not None or max_distance_sq is not None):
|
|
125
137
|
for _ in range(20):
|
|
126
|
-
|
|
127
|
-
center = (
|
|
138
|
+
cell_x, cell_y = RNG.choice(walkable_cells)
|
|
139
|
+
center = (
|
|
140
|
+
(cell_x * cell_size) + (cell_size / 2),
|
|
141
|
+
(cell_y * cell_size) + (cell_size / 2),
|
|
142
|
+
)
|
|
128
143
|
if view_rect is not None and view_rect.collidepoint(center):
|
|
129
144
|
continue
|
|
130
145
|
dx = center[0] - player.x
|
|
@@ -134,15 +149,19 @@ def find_nearby_offscreen_spawn_position(
|
|
|
134
149
|
continue
|
|
135
150
|
if max_distance_sq is not None and dist_sq > max_distance_sq:
|
|
136
151
|
continue
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
152
|
+
fallback_extent = cell_size * 0.2
|
|
153
|
+
fallback_x = RNG.uniform(-fallback_extent, fallback_extent)
|
|
154
|
+
fallback_y = RNG.uniform(-fallback_extent, fallback_extent)
|
|
155
|
+
return (int(center[0] + fallback_x), int(center[1] + fallback_y))
|
|
156
|
+
fallback_cell_x, fallback_cell_y = RNG.choice(walkable_cells)
|
|
157
|
+
fallback_center_x = (fallback_cell_x * cell_size) + (cell_size / 2)
|
|
158
|
+
fallback_center_y = (fallback_cell_y * cell_size) + (cell_size / 2)
|
|
159
|
+
fallback_extent = cell_size * 0.35
|
|
160
|
+
fallback_x = RNG.uniform(-fallback_extent, fallback_extent)
|
|
161
|
+
fallback_y = RNG.uniform(-fallback_extent, fallback_extent)
|
|
143
162
|
return (
|
|
144
|
-
int(
|
|
145
|
-
int(
|
|
163
|
+
int(fallback_center_x + fallback_x),
|
|
164
|
+
int(fallback_center_y + fallback_y),
|
|
146
165
|
)
|
|
147
166
|
|
|
148
167
|
|
|
@@ -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]]:
|
|
@@ -218,9 +299,10 @@ def _place_walls_grid_wire(
|
|
|
218
299
|
grid[y][x] = "1"
|
|
219
300
|
|
|
220
301
|
|
|
221
|
-
def
|
|
302
|
+
def _place_walls_sparse_moore(
|
|
222
303
|
grid: list[list[str]],
|
|
223
304
|
*,
|
|
305
|
+
density: float = SPARSE_WALL_DENSITY,
|
|
224
306
|
forbidden_cells: set[tuple[int, int]] | None = None,
|
|
225
307
|
) -> None:
|
|
226
308
|
"""Place isolated wall tiles at a low density, avoiding adjacency."""
|
|
@@ -234,7 +316,7 @@ def _place_walls_sparse(
|
|
|
234
316
|
continue
|
|
235
317
|
if grid[y][x] != ".":
|
|
236
318
|
continue
|
|
237
|
-
if RNG.random() >=
|
|
319
|
+
if RNG.random() >= density:
|
|
238
320
|
continue
|
|
239
321
|
if (
|
|
240
322
|
grid[y - 1][x] == "1"
|
|
@@ -250,11 +332,40 @@ def _place_walls_sparse(
|
|
|
250
332
|
grid[y][x] = "1"
|
|
251
333
|
|
|
252
334
|
|
|
335
|
+
def _place_walls_sparse_ortho(
|
|
336
|
+
grid: list[list[str]],
|
|
337
|
+
*,
|
|
338
|
+
density: float = SPARSE_WALL_DENSITY,
|
|
339
|
+
forbidden_cells: set[tuple[int, int]] | None = None,
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Place isolated wall tiles at a low density, avoiding orthogonal adjacency."""
|
|
342
|
+
cols, rows = len(grid[0]), len(grid)
|
|
343
|
+
forbidden = _collect_exit_adjacent_cells(grid)
|
|
344
|
+
if forbidden_cells:
|
|
345
|
+
forbidden |= forbidden_cells
|
|
346
|
+
for y in range(2, rows - 2):
|
|
347
|
+
for x in range(2, cols - 2):
|
|
348
|
+
if (x, y) in forbidden:
|
|
349
|
+
continue
|
|
350
|
+
if grid[y][x] != ".":
|
|
351
|
+
continue
|
|
352
|
+
if RNG.random() >= density:
|
|
353
|
+
continue
|
|
354
|
+
if (
|
|
355
|
+
grid[y - 1][x] == "1"
|
|
356
|
+
or grid[y + 1][x] == "1"
|
|
357
|
+
or grid[y][x - 1] == "1"
|
|
358
|
+
or grid[y][x + 1] == "1"
|
|
359
|
+
):
|
|
360
|
+
continue
|
|
361
|
+
grid[y][x] = "1"
|
|
362
|
+
|
|
253
363
|
WALL_ALGORITHMS = {
|
|
254
364
|
"default": _place_walls_default,
|
|
255
365
|
"empty": _place_walls_empty,
|
|
256
366
|
"grid_wire": _place_walls_grid_wire,
|
|
257
|
-
"
|
|
367
|
+
"sparse_moore": _place_walls_sparse_moore,
|
|
368
|
+
"sparse_ortho": _place_walls_sparse_ortho,
|
|
258
369
|
}
|
|
259
370
|
|
|
260
371
|
|
|
@@ -281,6 +392,30 @@ def _place_steel_beams(
|
|
|
281
392
|
return beams
|
|
282
393
|
|
|
283
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
|
+
|
|
284
419
|
def _pick_empty_cell(
|
|
285
420
|
grid: list[list[str]],
|
|
286
421
|
margin: int,
|
|
@@ -302,7 +437,12 @@ def _pick_empty_cell(
|
|
|
302
437
|
|
|
303
438
|
|
|
304
439
|
def _generate_random_blueprint(
|
|
305
|
-
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,
|
|
306
446
|
) -> dict:
|
|
307
447
|
grid = _init_grid(cols, rows)
|
|
308
448
|
_place_exits(grid, EXITS_PER_SIDE)
|
|
@@ -320,15 +460,90 @@ def _generate_random_blueprint(
|
|
|
320
460
|
grid[zy][zx] = "Z"
|
|
321
461
|
reserved_cells.add((zx, zy))
|
|
322
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
|
+
|
|
323
478
|
# Select and run the wall placement algorithm (after reserving spawns)
|
|
479
|
+
sparse_density = SPARSE_WALL_DENSITY
|
|
480
|
+
original_wall_algo = wall_algo
|
|
481
|
+
if wall_algo == "sparse":
|
|
482
|
+
print(
|
|
483
|
+
"WARNING: 'sparse' is deprecated. Use 'sparse_moore' instead."
|
|
484
|
+
)
|
|
485
|
+
wall_algo = "sparse_moore"
|
|
486
|
+
elif wall_algo.startswith("sparse."):
|
|
487
|
+
print(
|
|
488
|
+
"WARNING: 'sparse.<int>%' is deprecated. Use "
|
|
489
|
+
"'sparse_moore.<int>%' instead."
|
|
490
|
+
)
|
|
491
|
+
suffix = wall_algo[len("sparse.") :]
|
|
492
|
+
wall_algo = "sparse_moore"
|
|
493
|
+
if suffix.endswith("%") and suffix[:-1].isdigit():
|
|
494
|
+
percent = int(suffix[:-1])
|
|
495
|
+
if 0 <= percent <= 100:
|
|
496
|
+
sparse_density = percent / 100.0
|
|
497
|
+
else:
|
|
498
|
+
print(
|
|
499
|
+
"WARNING: Sparse wall density must be 0-100%. "
|
|
500
|
+
f"Got '{suffix}'. Falling back to default sparse density."
|
|
501
|
+
)
|
|
502
|
+
else:
|
|
503
|
+
print(
|
|
504
|
+
"WARNING: Invalid sparse wall format. Use "
|
|
505
|
+
"'sparse_moore.<int>%' or 'sparse_ortho.<int>%'. "
|
|
506
|
+
f"Got '{original_wall_algo}'. Falling back to default sparse density."
|
|
507
|
+
)
|
|
508
|
+
if wall_algo.startswith("sparse_moore.") or wall_algo.startswith("sparse_ortho."):
|
|
509
|
+
base, suffix = wall_algo.split(".", 1)
|
|
510
|
+
if suffix.endswith("%") and suffix[:-1].isdigit():
|
|
511
|
+
percent = int(suffix[:-1])
|
|
512
|
+
if 0 <= percent <= 100:
|
|
513
|
+
sparse_density = percent / 100.0
|
|
514
|
+
wall_algo = base
|
|
515
|
+
else:
|
|
516
|
+
print(
|
|
517
|
+
"WARNING: Sparse wall density must be 0-100%. "
|
|
518
|
+
f"Got '{suffix}'. Falling back to default sparse density."
|
|
519
|
+
)
|
|
520
|
+
wall_algo = base
|
|
521
|
+
else:
|
|
522
|
+
print(
|
|
523
|
+
"WARNING: Invalid sparse wall format. Use "
|
|
524
|
+
"'sparse_moore.<int>%' or 'sparse_ortho.<int>%'. "
|
|
525
|
+
f"Got '{wall_algo}'. Falling back to default sparse density."
|
|
526
|
+
)
|
|
527
|
+
wall_algo = base
|
|
528
|
+
|
|
324
529
|
if wall_algo not in WALL_ALGORITHMS:
|
|
325
530
|
print(
|
|
326
531
|
f"WARNING: Unknown wall algorithm '{wall_algo}'. Falling back to 'default'."
|
|
327
532
|
)
|
|
328
533
|
wall_algo = "default"
|
|
329
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
|
+
|
|
330
542
|
algo_func = WALL_ALGORITHMS[wall_algo]
|
|
331
|
-
|
|
543
|
+
if wall_algo in {"sparse_moore", "sparse_ortho"}:
|
|
544
|
+
algo_func(grid, density=sparse_density, forbidden_cells=reserved_cells)
|
|
545
|
+
else:
|
|
546
|
+
algo_func(grid, forbidden_cells=reserved_cells)
|
|
332
547
|
|
|
333
548
|
steel_beams = _place_steel_beams(
|
|
334
549
|
grid, chance=steel_chance, forbidden_cells=reserved_cells
|
|
@@ -339,7 +554,13 @@ def _generate_random_blueprint(
|
|
|
339
554
|
|
|
340
555
|
|
|
341
556
|
def choose_blueprint(
|
|
342
|
-
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,
|
|
343
564
|
) -> dict:
|
|
344
565
|
# Currently only random generation; hook for future variants.
|
|
345
566
|
steel_conf = config.get("steel_beams", {})
|
|
@@ -347,6 +568,22 @@ def choose_blueprint(
|
|
|
347
568
|
steel_chance = float(steel_conf.get("chance", STEEL_BEAM_CHANCE))
|
|
348
569
|
except (TypeError, ValueError):
|
|
349
570
|
steel_chance = STEEL_BEAM_CHANCE
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
}
|
zombie_escape/locales/ui.ja.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"settings": "設定",
|
|
17
17
|
"quit": "終了",
|
|
18
18
|
"locked_suffix": "[Locked]",
|
|
19
|
-
"window_hint": "[キーでウィンドウを小さく、]キーで大きく。\nF:
|
|
19
|
+
"window_hint": "[キーでウィンドウを小さく、]キーで大きく。\nF: フルスクリーン切替。",
|
|
20
20
|
"seed_label": "シード: %{value}",
|
|
21
21
|
"seed_hint": "数字キーで入力、BSでクリア",
|
|
22
22
|
"seed_empty": "(自動)",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"resources": "リソース"
|
|
26
26
|
},
|
|
27
27
|
"readme": "README を開く",
|
|
28
|
+
"readme_stage6": "ステージ6以降の説明を開く",
|
|
28
29
|
"hints": {
|
|
29
30
|
"navigate": "上下: 項目選択",
|
|
30
31
|
"page_switch": "左右: ステージ群切替",
|
|
@@ -33,7 +34,8 @@
|
|
|
33
34
|
"option_help": {
|
|
34
35
|
"settings": "設定画面を開いて言語や補助オプションを変更します。",
|
|
35
36
|
"quit": "ゲームを終了します。",
|
|
36
|
-
"readme": "GitHub の README ページをブラウザで開きます。"
|
|
37
|
+
"readme": "GitHub の README ページをブラウザで開きます。",
|
|
38
|
+
"readme_stage6": "ステージ6以降の説明ページをブラウザで開きます。"
|
|
37
39
|
}
|
|
38
40
|
},
|
|
39
41
|
"stages": {
|
|
@@ -127,6 +129,10 @@
|
|
|
127
129
|
"stage15": {
|
|
128
130
|
"name": "#15 分断ライン",
|
|
129
131
|
"description": "建物中央を分断する危険地帯。横断には注意。"
|
|
132
|
+
},
|
|
133
|
+
"stage16": {
|
|
134
|
+
"name": "#16 奈落",
|
|
135
|
+
"description": "崩落した床パネルを回り込んでゾンビを誘い込もう。"
|
|
130
136
|
}
|
|
131
137
|
},
|
|
132
138
|
"status": {
|
|
@@ -198,10 +204,14 @@
|
|
|
198
204
|
"game_over": {
|
|
199
205
|
"win": "脱出成功!",
|
|
200
206
|
"lose": "ゲームオーバー",
|
|
207
|
+
"fell_into_abyss": "奈落の底へ落ちてしまった...",
|
|
201
208
|
"prompt": "ESC/Space/Select/South: タイトルへ R: 再挑戦",
|
|
202
209
|
"scream": "ぎゃあーーー!!",
|
|
203
210
|
"survivors_summary": "救出人数: %{count}",
|
|
204
211
|
"endurance_duration": "逃げ延びた時間 %{time}"
|
|
212
|
+
},
|
|
213
|
+
"errors": {
|
|
214
|
+
"map_generation_failed": "マップの生成に失敗しました。シード値を変えるか、別のステージを選んでください。"
|
|
205
215
|
}
|
|
206
216
|
}
|
|
207
217
|
}
|