zombie-escape 1.14.4__py3-none-any.whl → 1.15.2__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/config.py +1 -0
- zombie_escape/entities.py +126 -199
- zombie_escape/entities_constants.py +11 -1
- zombie_escape/export_images.py +4 -4
- zombie_escape/font_utils.py +47 -0
- zombie_escape/gameplay/__init__.py +2 -1
- zombie_escape/gameplay/constants.py +4 -0
- zombie_escape/gameplay/interactions.py +83 -16
- zombie_escape/gameplay/layout.py +9 -15
- zombie_escape/gameplay/movement.py +45 -29
- zombie_escape/gameplay/spawn.py +15 -29
- zombie_escape/gameplay/state.py +62 -7
- zombie_escape/gameplay/survivors.py +61 -10
- zombie_escape/gameplay/utils.py +33 -0
- zombie_escape/level_blueprints.py +35 -31
- zombie_escape/level_constants.py +2 -2
- zombie_escape/locales/ui.en.json +19 -8
- zombie_escape/locales/ui.ja.json +19 -8
- zombie_escape/localization.py +7 -1
- zombie_escape/models.py +21 -6
- zombie_escape/render/__init__.py +2 -2
- zombie_escape/render/core.py +113 -81
- zombie_escape/render/hud.py +112 -40
- zombie_escape/render/overview.py +93 -2
- zombie_escape/render/shadows.py +2 -2
- zombie_escape/render_constants.py +12 -0
- zombie_escape/screens/__init__.py +6 -189
- zombie_escape/screens/game_over.py +8 -21
- zombie_escape/screens/gameplay.py +71 -26
- zombie_escape/screens/settings.py +114 -43
- zombie_escape/screens/title.py +128 -47
- zombie_escape/stage_constants.py +37 -8
- zombie_escape/windowing.py +508 -0
- zombie_escape/world_grid.py +7 -5
- zombie_escape/zombie_escape.py +26 -13
- {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/METADATA +24 -24
- zombie_escape-1.15.2.dist-info/RECORD +54 -0
- zombie_escape-1.14.4.dist-info/RECORD +0 -53
- {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/WHEEL +0 -0
- {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/entry_points.txt +0 -0
- {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/licenses/LICENSE.txt +0 -0
zombie_escape/gameplay/state.py
CHANGED
|
@@ -4,11 +4,51 @@ from typing import Any
|
|
|
4
4
|
|
|
5
5
|
import pygame
|
|
6
6
|
|
|
7
|
-
from ..colors import DAWN_AMBIENT_PALETTE_KEY, ambient_palette_key_for_flashlights
|
|
7
|
+
from ..colors import DAWN_AMBIENT_PALETTE_KEY, WHITE, ambient_palette_key_for_flashlights
|
|
8
8
|
from ..entities_constants import SURVIVOR_MAX_SAFE_PASSENGERS
|
|
9
|
-
from ..
|
|
9
|
+
from ..localization import translate as tr
|
|
10
|
+
from ..models import GameData, Groups, LevelLayout, ProgressState, Stage, TimedMessage
|
|
11
|
+
from ..screen_constants import FPS
|
|
10
12
|
from ..entities import Camera
|
|
11
13
|
from .ambient import _set_ambient_palette
|
|
14
|
+
from .constants import INTRO_MESSAGE_DISPLAY_FRAMES
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def frames_to_ms(frames: int) -> int:
|
|
18
|
+
if frames <= 0:
|
|
19
|
+
return 0
|
|
20
|
+
return max(1, int(round((1000 / max(1, FPS)) * frames)))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ms_to_frames(ms: int) -> int:
|
|
24
|
+
if ms <= 0:
|
|
25
|
+
return 0
|
|
26
|
+
return max(1, int(round((max(1, FPS) / 1000) * ms)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def schedule_timed_message(
|
|
30
|
+
state: ProgressState,
|
|
31
|
+
text: str | None,
|
|
32
|
+
*,
|
|
33
|
+
duration_frames: int,
|
|
34
|
+
clear_on_input: bool = False,
|
|
35
|
+
color: tuple[int, int, int] | None = None,
|
|
36
|
+
align: str = "center",
|
|
37
|
+
now_ms: int | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
if not text:
|
|
40
|
+
state.timed_message = None
|
|
41
|
+
return
|
|
42
|
+
duration_ms = frames_to_ms(duration_frames)
|
|
43
|
+
if now_ms is None:
|
|
44
|
+
now_ms = state.elapsed_play_ms
|
|
45
|
+
state.timed_message = TimedMessage(
|
|
46
|
+
text=text,
|
|
47
|
+
expires_at_ms=now_ms + duration_ms,
|
|
48
|
+
clear_on_input=clear_on_input,
|
|
49
|
+
color=color,
|
|
50
|
+
align=align,
|
|
51
|
+
)
|
|
12
52
|
|
|
13
53
|
|
|
14
54
|
def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
@@ -19,10 +59,12 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
19
59
|
starts_with_flashlight = False
|
|
20
60
|
initial_flashlights = 1 if starts_with_flashlight else 0
|
|
21
61
|
initial_palette_key = ambient_palette_key_for_flashlights(initial_flashlights)
|
|
62
|
+
intro_message = tr(stage.intro_key) if stage.intro_key else None
|
|
22
63
|
game_state = ProgressState(
|
|
23
64
|
game_over=False,
|
|
24
65
|
game_won=False,
|
|
25
|
-
|
|
66
|
+
timed_message=None,
|
|
67
|
+
fade_in_started_at_ms=None,
|
|
26
68
|
game_over_at=None,
|
|
27
69
|
scaled_overview=None,
|
|
28
70
|
overview_created=False,
|
|
@@ -36,9 +78,9 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
36
78
|
ambient_palette_key=initial_palette_key,
|
|
37
79
|
hint_expires_at=0,
|
|
38
80
|
hint_target_type=None,
|
|
39
|
-
fuel_message_until=0,
|
|
40
81
|
buddy_rescued=0,
|
|
41
82
|
buddy_onboard=0,
|
|
83
|
+
buddy_merged_count=0,
|
|
42
84
|
survivors_onboard=0,
|
|
43
85
|
survivors_rescued=0,
|
|
44
86
|
survivor_messages=[],
|
|
@@ -59,6 +101,18 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
59
101
|
player_wall_target_cell=None,
|
|
60
102
|
player_wall_target_ttl=0,
|
|
61
103
|
)
|
|
104
|
+
if intro_message:
|
|
105
|
+
schedule_timed_message(
|
|
106
|
+
game_state,
|
|
107
|
+
intro_message,
|
|
108
|
+
duration_frames=INTRO_MESSAGE_DISPLAY_FRAMES,
|
|
109
|
+
clear_on_input=True,
|
|
110
|
+
color=WHITE,
|
|
111
|
+
align="left",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Start fade-in from black when gameplay begins.
|
|
115
|
+
game_state.fade_in_started_at_ms = game_state.elapsed_play_ms
|
|
62
116
|
|
|
63
117
|
# Create sprite groups
|
|
64
118
|
all_sprites = pygame.sprite.LayeredUpdates()
|
|
@@ -67,7 +121,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
67
121
|
survivor_group = pygame.sprite.Group()
|
|
68
122
|
|
|
69
123
|
# Create camera
|
|
70
|
-
cell_size = stage.
|
|
124
|
+
cell_size = stage.cell_size
|
|
71
125
|
level_width = stage.grid_cols * cell_size
|
|
72
126
|
level_height = stage.grid_rows * cell_size
|
|
73
127
|
camera = Camera(level_width, level_height)
|
|
@@ -86,6 +140,8 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
86
140
|
camera=camera,
|
|
87
141
|
layout=LevelLayout(
|
|
88
142
|
field_rect=field_rect,
|
|
143
|
+
grid_cols=stage.grid_cols,
|
|
144
|
+
grid_rows=stage.grid_rows,
|
|
89
145
|
outside_cells=set(),
|
|
90
146
|
walkable_cells=[],
|
|
91
147
|
outer_wall_cells=set(),
|
|
@@ -101,8 +157,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
|
|
|
101
157
|
},
|
|
102
158
|
stage=stage,
|
|
103
159
|
cell_size=cell_size,
|
|
104
|
-
|
|
105
|
-
level_height=level_height,
|
|
160
|
+
blueprint=None,
|
|
106
161
|
fuel=None,
|
|
107
162
|
flashlights=[],
|
|
108
163
|
shoes=[],
|
|
@@ -23,7 +23,7 @@ from ..rng import get_rng
|
|
|
23
23
|
from ..entities import Survivor, Zombie, spritecollideany_walls
|
|
24
24
|
from ..world_grid import WallIndex
|
|
25
25
|
from .spawn import _create_zombie
|
|
26
|
-
from .utils import find_nearby_offscreen_spawn_position, rect_visible_on_screen
|
|
26
|
+
from .utils import find_nearby_offscreen_spawn_position, is_entity_in_fov, rect_visible_on_screen
|
|
27
27
|
|
|
28
28
|
RNG = get_rng()
|
|
29
29
|
|
|
@@ -38,6 +38,7 @@ def update_survivors(
|
|
|
38
38
|
survivor_group = game_data.groups.survivor_group
|
|
39
39
|
wall_group = game_data.groups.wall_group
|
|
40
40
|
player = game_data.player
|
|
41
|
+
assert player is not None
|
|
41
42
|
car = game_data.car
|
|
42
43
|
if not player:
|
|
43
44
|
return
|
|
@@ -51,14 +52,7 @@ def update_survivors(
|
|
|
51
52
|
wall_group,
|
|
52
53
|
wall_index=wall_index,
|
|
53
54
|
cell_size=game_data.cell_size,
|
|
54
|
-
|
|
55
|
-
pitfall_cells=game_data.layout.pitfall_cells,
|
|
56
|
-
walkable_cells=game_data.layout.walkable_cells,
|
|
57
|
-
bevel_corners=game_data.layout.bevel_corners,
|
|
58
|
-
grid_cols=game_data.stage.grid_cols,
|
|
59
|
-
grid_rows=game_data.stage.grid_rows,
|
|
60
|
-
level_width=game_data.level_width,
|
|
61
|
-
level_height=game_data.level_height,
|
|
55
|
+
layout=game_data.layout,
|
|
62
56
|
wall_target_cell=wall_target_cell,
|
|
63
57
|
)
|
|
64
58
|
|
|
@@ -84,6 +78,50 @@ def update_survivors(
|
|
|
84
78
|
for survivor in survivors:
|
|
85
79
|
_separate_from_point(survivor, player_point, player_overlap)
|
|
86
80
|
|
|
81
|
+
def _resolve_wall_overlap(survivor: Survivor) -> None:
|
|
82
|
+
for _ in range(4):
|
|
83
|
+
hit_wall = spritecollideany_walls(
|
|
84
|
+
survivor,
|
|
85
|
+
wall_group,
|
|
86
|
+
wall_index=wall_index,
|
|
87
|
+
cell_size=game_data.cell_size,
|
|
88
|
+
grid_cols=game_data.stage.grid_cols,
|
|
89
|
+
grid_rows=game_data.stage.grid_rows,
|
|
90
|
+
)
|
|
91
|
+
if not hit_wall:
|
|
92
|
+
return
|
|
93
|
+
cx, cy = survivor.rect.center
|
|
94
|
+
radius = survivor.radius
|
|
95
|
+
rect = hit_wall.rect
|
|
96
|
+
closest_x = min(max(cx, rect.left), rect.right)
|
|
97
|
+
closest_y = min(max(cy, rect.top), rect.bottom)
|
|
98
|
+
dx = cx - closest_x
|
|
99
|
+
dy = cy - closest_y
|
|
100
|
+
if dx == 0 and dy == 0:
|
|
101
|
+
left_pen = cx - rect.left
|
|
102
|
+
right_pen = rect.right - cx
|
|
103
|
+
top_pen = cy - rect.top
|
|
104
|
+
bottom_pen = rect.bottom - cy
|
|
105
|
+
min_pen = min(left_pen, right_pen, top_pen, bottom_pen)
|
|
106
|
+
if min_pen == left_pen:
|
|
107
|
+
cx = rect.left - radius
|
|
108
|
+
elif min_pen == right_pen:
|
|
109
|
+
cx = rect.right + radius
|
|
110
|
+
elif min_pen == top_pen:
|
|
111
|
+
cy = rect.top - radius
|
|
112
|
+
else:
|
|
113
|
+
cy = rect.bottom + radius
|
|
114
|
+
else:
|
|
115
|
+
dist = math.hypot(dx, dy)
|
|
116
|
+
if dist == 0:
|
|
117
|
+
return
|
|
118
|
+
push = max(1.0, radius - dist)
|
|
119
|
+
cx += (dx / dist) * push
|
|
120
|
+
cy += (dy / dist) * push
|
|
121
|
+
survivor.x = float(cx)
|
|
122
|
+
survivor.y = float(cy)
|
|
123
|
+
survivor.rect.center = (int(survivor.x), int(survivor.y))
|
|
124
|
+
|
|
87
125
|
survivors_with_x = sorted(((survivor.x, survivor) for survivor in survivors), key=lambda item: item[0])
|
|
88
126
|
for i, (base_x, survivor) in enumerate(survivors_with_x):
|
|
89
127
|
for other_base_x, other in survivors_with_x[i + 1 :]:
|
|
@@ -107,6 +145,9 @@ def update_survivors(
|
|
|
107
145
|
survivor.rect.center = (int(survivor.x), int(survivor.y))
|
|
108
146
|
other.rect.center = (int(other.x), int(other.y))
|
|
109
147
|
|
|
148
|
+
for survivor in survivors:
|
|
149
|
+
_resolve_wall_overlap(survivor)
|
|
150
|
+
|
|
110
151
|
|
|
111
152
|
def calculate_car_speed_for_passengers(passengers: int, *, capacity: int = SURVIVOR_MAX_SAFE_PASSENGERS) -> float:
|
|
112
153
|
cap = max(1, capacity)
|
|
@@ -240,6 +281,10 @@ def handle_survivor_zombie_collisions(game_data: GameData, config: dict[str, Any
|
|
|
240
281
|
camera = game_data.camera
|
|
241
282
|
walkable_cells = game_data.layout.walkable_cells
|
|
242
283
|
cell_size = game_data.cell_size
|
|
284
|
+
player = game_data.player
|
|
285
|
+
car = game_data.car
|
|
286
|
+
active_car = car if car and car.alive() else None
|
|
287
|
+
fov_target = active_car if player and player.in_car and active_car else player
|
|
243
288
|
|
|
244
289
|
for survivor in list(survivor_group):
|
|
245
290
|
if not survivor.alive():
|
|
@@ -269,7 +314,13 @@ def handle_survivor_zombie_collisions(game_data: GameData, config: dict[str, Any
|
|
|
269
314
|
|
|
270
315
|
if collided_zombie is None:
|
|
271
316
|
continue
|
|
272
|
-
|
|
317
|
+
survivor_on_screen = rect_visible_on_screen(camera, survivor.rect)
|
|
318
|
+
survivor_in_fov = is_entity_in_fov(
|
|
319
|
+
survivor.rect,
|
|
320
|
+
fov_target=fov_target,
|
|
321
|
+
flashlight_count=game_data.state.flashlight_count,
|
|
322
|
+
)
|
|
323
|
+
if not (survivor_on_screen and survivor_in_fov):
|
|
273
324
|
spawn_pos = find_nearby_offscreen_spawn_position(
|
|
274
325
|
walkable_cells,
|
|
275
326
|
cell_size,
|
zombie_escape/gameplay/utils.py
CHANGED
|
@@ -4,6 +4,12 @@ import pygame
|
|
|
4
4
|
|
|
5
5
|
from ..entities import Camera, Player, random_position_outside_building
|
|
6
6
|
from ..rng import get_rng
|
|
7
|
+
from ..render_constants import (
|
|
8
|
+
FLASHLIGHT_FOG_SCALE_ONE,
|
|
9
|
+
FLASHLIGHT_FOG_SCALE_TWO,
|
|
10
|
+
FOG_RADIUS_SCALE,
|
|
11
|
+
FOV_RADIUS,
|
|
12
|
+
)
|
|
7
13
|
from ..screen_constants import SCREEN_HEIGHT, SCREEN_WIDTH
|
|
8
14
|
|
|
9
15
|
LOGICAL_SCREEN_RECT = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
|
|
@@ -12,6 +18,8 @@ RNG = get_rng()
|
|
|
12
18
|
__all__ = [
|
|
13
19
|
"LOGICAL_SCREEN_RECT",
|
|
14
20
|
"rect_visible_on_screen",
|
|
21
|
+
"fov_radius_for_flashlights",
|
|
22
|
+
"is_entity_in_fov",
|
|
15
23
|
"find_interior_spawn_positions",
|
|
16
24
|
"find_nearby_offscreen_spawn_position",
|
|
17
25
|
"find_exterior_spawn_position",
|
|
@@ -24,6 +32,31 @@ def rect_visible_on_screen(camera: Camera | None, rect: pygame.Rect) -> bool:
|
|
|
24
32
|
return camera.apply_rect(rect).colliderect(LOGICAL_SCREEN_RECT)
|
|
25
33
|
|
|
26
34
|
|
|
35
|
+
def fov_radius_for_flashlights(flashlight_count: int) -> float:
|
|
36
|
+
count = max(0, int(flashlight_count))
|
|
37
|
+
if count <= 0:
|
|
38
|
+
scale = FOG_RADIUS_SCALE
|
|
39
|
+
elif count == 1:
|
|
40
|
+
scale = FLASHLIGHT_FOG_SCALE_ONE
|
|
41
|
+
else:
|
|
42
|
+
scale = FLASHLIGHT_FOG_SCALE_TWO
|
|
43
|
+
return FOV_RADIUS * scale
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_entity_in_fov(
|
|
47
|
+
entity_rect: pygame.Rect,
|
|
48
|
+
*,
|
|
49
|
+
fov_target: pygame.sprite.Sprite | None,
|
|
50
|
+
flashlight_count: int,
|
|
51
|
+
) -> bool:
|
|
52
|
+
if fov_target is None:
|
|
53
|
+
return False
|
|
54
|
+
fov_radius = fov_radius_for_flashlights(flashlight_count)
|
|
55
|
+
dx = entity_rect.centerx - fov_target.rect.centerx
|
|
56
|
+
dy = entity_rect.centery - fov_target.rect.centery
|
|
57
|
+
return (dx * dx + dy * dy) <= fov_radius * fov_radius
|
|
58
|
+
|
|
59
|
+
|
|
27
60
|
def _scatter_positions_on_walkable(
|
|
28
61
|
walkable_cells: list[tuple[int, int]],
|
|
29
62
|
cell_size: int,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Blueprint generator for randomized layouts.
|
|
2
2
|
|
|
3
3
|
from collections import deque
|
|
4
|
+
from dataclasses import dataclass, field
|
|
4
5
|
|
|
5
6
|
from .level_constants import (
|
|
6
7
|
DEFAULT_GRID_WIRE_WALL_LINES,
|
|
@@ -14,7 +15,6 @@ EXITS_PER_SIDE = 1 # currently fixed to 1 per side (can be tuned)
|
|
|
14
15
|
WALL_MIN_LEN = 3
|
|
15
16
|
WALL_MAX_LEN = 10
|
|
16
17
|
SPAWN_MARGIN = 3 # keep spawns away from walls/edges
|
|
17
|
-
SPAWN_ZOMBIES = 3
|
|
18
18
|
|
|
19
19
|
RNG = get_rng()
|
|
20
20
|
|
|
@@ -23,30 +23,37 @@ class MapGenerationError(Exception):
|
|
|
23
23
|
"""Raised when a valid map cannot be generated after several attempts."""
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
@dataclass
|
|
27
|
+
class Blueprint:
|
|
28
|
+
grid: list[str]
|
|
29
|
+
steel_cells: set[tuple[int, int]] = field(default_factory=set)
|
|
30
|
+
car_reachable_cells: set[tuple[int, int]] = field(default_factory=set)
|
|
31
|
+
|
|
32
|
+
|
|
26
33
|
def validate_car_connectivity(grid: list[str]) -> set[tuple[int, int]] | None:
|
|
27
34
|
"""Check if the Car can reach at least one exit (4-way BFS).
|
|
28
|
-
Returns the set of reachable
|
|
35
|
+
Returns the set of reachable cells if valid, otherwise None.
|
|
29
36
|
"""
|
|
30
37
|
rows = len(grid)
|
|
31
38
|
cols = len(grid[0])
|
|
32
39
|
|
|
33
40
|
start_pos = None
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
passable_cells = set()
|
|
42
|
+
exit_cells = set()
|
|
36
43
|
|
|
37
44
|
for y in range(rows):
|
|
38
45
|
for x in range(cols):
|
|
39
46
|
ch = grid[y][x]
|
|
40
47
|
if ch == "C":
|
|
41
48
|
start_pos = (x, y)
|
|
42
|
-
if ch not in ("x", "B"):
|
|
43
|
-
|
|
49
|
+
if ch not in ("x", "B", "O"):
|
|
50
|
+
passable_cells.add((x, y))
|
|
44
51
|
if ch == "E":
|
|
45
|
-
|
|
52
|
+
exit_cells.add((x, y))
|
|
46
53
|
|
|
47
54
|
if start_pos is None:
|
|
48
55
|
# If no car candidate, we can't validate car pathing.
|
|
49
|
-
return
|
|
56
|
+
return passable_cells
|
|
50
57
|
|
|
51
58
|
reachable = {start_pos}
|
|
52
59
|
queue = deque([start_pos])
|
|
@@ -54,23 +61,23 @@ def validate_car_connectivity(grid: list[str]) -> set[tuple[int, int]] | None:
|
|
|
54
61
|
x, y = queue.popleft()
|
|
55
62
|
for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
|
|
56
63
|
nx, ny = x + dx, y + dy
|
|
57
|
-
if (nx, ny) in
|
|
64
|
+
if (nx, ny) in passable_cells and (nx, ny) not in reachable:
|
|
58
65
|
reachable.add((nx, ny))
|
|
59
66
|
queue.append((nx, ny))
|
|
60
67
|
|
|
61
68
|
# Car must reach at least one exit
|
|
62
|
-
if
|
|
69
|
+
if exit_cells and not any(e in reachable for e in exit_cells):
|
|
63
70
|
return None
|
|
64
71
|
return reachable
|
|
65
72
|
|
|
66
73
|
|
|
67
74
|
def validate_humanoid_connectivity(grid: list[str]) -> bool:
|
|
68
|
-
"""Check if all floor
|
|
75
|
+
"""Check if all floor cells are reachable by Humans (8-way BFS with jumps)."""
|
|
69
76
|
rows = len(grid)
|
|
70
77
|
cols = len(grid[0])
|
|
71
78
|
|
|
72
79
|
start_pos = None
|
|
73
|
-
|
|
80
|
+
passable_cells = set()
|
|
74
81
|
|
|
75
82
|
for y in range(rows):
|
|
76
83
|
for x in range(cols):
|
|
@@ -78,7 +85,7 @@ def validate_humanoid_connectivity(grid: list[str]) -> bool:
|
|
|
78
85
|
if ch == "P":
|
|
79
86
|
start_pos = (x, y)
|
|
80
87
|
if ch not in ("x", "B"):
|
|
81
|
-
|
|
88
|
+
passable_cells.add((x, y))
|
|
82
89
|
|
|
83
90
|
if start_pos is None:
|
|
84
91
|
return False
|
|
@@ -89,16 +96,16 @@ def validate_humanoid_connectivity(grid: list[str]) -> bool:
|
|
|
89
96
|
x, y = queue.popleft()
|
|
90
97
|
for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (1, -1), (-1, 1), (-1, -1)):
|
|
91
98
|
nx, ny = x + dx, y + dy
|
|
92
|
-
if (nx, ny) in
|
|
99
|
+
if (nx, ny) in passable_cells and (nx, ny) not in reachable:
|
|
93
100
|
reachable.add((nx, ny))
|
|
94
101
|
queue.append((nx, ny))
|
|
95
102
|
|
|
96
|
-
return len(
|
|
103
|
+
return len(passable_cells) == len(reachable)
|
|
97
104
|
|
|
98
105
|
|
|
99
106
|
def validate_connectivity(grid: list[str]) -> set[tuple[int, int]] | None:
|
|
100
107
|
"""Validate both car and humanoid movement conditions.
|
|
101
|
-
Returns car reachable
|
|
108
|
+
Returns car reachable cells if both pass, otherwise None.
|
|
102
109
|
"""
|
|
103
110
|
car_reachable = validate_car_connectivity(grid)
|
|
104
111
|
if car_reachable is None:
|
|
@@ -190,7 +197,7 @@ def _place_walls_default(
|
|
|
190
197
|
for i in range(length):
|
|
191
198
|
if (x + i, y) in forbidden:
|
|
192
199
|
continue
|
|
193
|
-
if grid[y][x + i]
|
|
200
|
+
if grid[y][x + i] == ".":
|
|
194
201
|
grid[y][x + i] = "1"
|
|
195
202
|
else:
|
|
196
203
|
x = rng(2, cols - 3)
|
|
@@ -198,7 +205,7 @@ def _place_walls_default(
|
|
|
198
205
|
for i in range(length):
|
|
199
206
|
if (x, y + i) in forbidden:
|
|
200
207
|
continue
|
|
201
|
-
if grid[y + i][x]
|
|
208
|
+
if grid[y + i][x] == ".":
|
|
202
209
|
grid[y + i][x] = "1"
|
|
203
210
|
|
|
204
211
|
|
|
@@ -317,7 +324,7 @@ def _place_walls_sparse_moore(
|
|
|
317
324
|
density: float = DEFAULT_SPARSE_WALL_DENSITY,
|
|
318
325
|
forbidden_cells: set[tuple[int, int]] | None = None,
|
|
319
326
|
) -> None:
|
|
320
|
-
"""Place isolated wall
|
|
327
|
+
"""Place isolated wall cells at a low density, avoiding adjacency."""
|
|
321
328
|
cols, rows = len(grid[0]), len(grid)
|
|
322
329
|
forbidden = _collect_exit_adjacent_cells(grid)
|
|
323
330
|
if forbidden_cells:
|
|
@@ -350,7 +357,7 @@ def _place_walls_sparse_ortho(
|
|
|
350
357
|
density: float = DEFAULT_SPARSE_WALL_DENSITY,
|
|
351
358
|
forbidden_cells: set[tuple[int, int]] | None = None,
|
|
352
359
|
) -> None:
|
|
353
|
-
"""Place isolated wall
|
|
360
|
+
"""Place isolated wall cells at a low density, avoiding orthogonal adjacency."""
|
|
354
361
|
cols, rows = len(grid[0]), len(grid)
|
|
355
362
|
forbidden = _collect_exit_adjacent_cells(grid)
|
|
356
363
|
if forbidden_cells:
|
|
@@ -407,7 +414,7 @@ def _place_pitfalls(
|
|
|
407
414
|
pitfall_zones: list[tuple[int, int, int, int]] | None = None,
|
|
408
415
|
forbidden_cells: set[tuple[int, int]] | None = None,
|
|
409
416
|
) -> None:
|
|
410
|
-
"""Replace empty floor
|
|
417
|
+
"""Replace empty floor cells with pitfalls based on density."""
|
|
411
418
|
cols, rows = len(grid[0]), len(grid)
|
|
412
419
|
forbidden = _collect_exit_adjacent_cells(grid)
|
|
413
420
|
if forbidden_cells:
|
|
@@ -468,11 +475,11 @@ def _generate_random_blueprint(
|
|
|
468
475
|
wall_algo: str = "default",
|
|
469
476
|
pitfall_density: float = 0.0,
|
|
470
477
|
pitfall_zones: list[tuple[int, int, int, int]] | None = None,
|
|
471
|
-
) ->
|
|
478
|
+
) -> Blueprint:
|
|
472
479
|
grid = _init_grid(cols, rows)
|
|
473
480
|
_place_exits(grid, EXITS_PER_SIDE)
|
|
474
481
|
|
|
475
|
-
# Spawns: player, car
|
|
482
|
+
# Spawns: player, car
|
|
476
483
|
reserved_cells: set[tuple[int, int]] = set()
|
|
477
484
|
px, py = _pick_empty_cell(grid, SPAWN_MARGIN)
|
|
478
485
|
grid[py][px] = "P"
|
|
@@ -480,10 +487,7 @@ def _generate_random_blueprint(
|
|
|
480
487
|
cx, cy = _pick_empty_cell(grid, SPAWN_MARGIN)
|
|
481
488
|
grid[cy][cx] = "C"
|
|
482
489
|
reserved_cells.add((cx, cy))
|
|
483
|
-
|
|
484
|
-
zx, zy = _pick_empty_cell(grid, SPAWN_MARGIN)
|
|
485
|
-
grid[zy][zx] = "Z"
|
|
486
|
-
reserved_cells.add((zx, zy))
|
|
490
|
+
# (No zombie candidate cells; initial spawns are handled by gameplay.)
|
|
487
491
|
|
|
488
492
|
# Items
|
|
489
493
|
fx, fy = _pick_empty_cell(grid, SPAWN_MARGIN)
|
|
@@ -591,7 +595,7 @@ def _generate_random_blueprint(
|
|
|
591
595
|
steel_beams = _place_steel_beams(grid, chance=steel_chance, forbidden_cells=reserved_cells)
|
|
592
596
|
|
|
593
597
|
blueprint_rows = ["".join(row) for row in grid]
|
|
594
|
-
return
|
|
598
|
+
return Blueprint(grid=blueprint_rows, steel_cells=steel_beams)
|
|
595
599
|
|
|
596
600
|
|
|
597
601
|
def choose_blueprint(
|
|
@@ -603,7 +607,7 @@ def choose_blueprint(
|
|
|
603
607
|
pitfall_density: float = 0.0,
|
|
604
608
|
pitfall_zones: list[tuple[int, int, int, int]] | None = None,
|
|
605
609
|
base_seed: int | None = None,
|
|
606
|
-
) ->
|
|
610
|
+
) -> Blueprint:
|
|
607
611
|
# Currently only random generation; hook for future variants.
|
|
608
612
|
steel_conf = config.get("steel_beams", {})
|
|
609
613
|
try:
|
|
@@ -624,9 +628,9 @@ def choose_blueprint(
|
|
|
624
628
|
pitfall_zones=pitfall_zones,
|
|
625
629
|
)
|
|
626
630
|
|
|
627
|
-
car_reachable = validate_connectivity(blueprint
|
|
631
|
+
car_reachable = validate_connectivity(blueprint.grid)
|
|
628
632
|
if car_reachable is not None:
|
|
629
|
-
blueprint
|
|
633
|
+
blueprint.car_reachable_cells = car_reachable
|
|
630
634
|
return blueprint
|
|
631
635
|
|
|
632
636
|
raise MapGenerationError("Connectivity validation failed after 20 attempts")
|
zombie_escape/level_constants.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
DEFAULT_GRID_COLS = 48
|
|
6
6
|
DEFAULT_GRID_ROWS = 30
|
|
7
|
-
|
|
7
|
+
DEFAULT_CELL_SIZE = 50 # world units per cell; adjust to scale the whole map
|
|
8
8
|
DEFAULT_WALL_LINES = 80 # reduced density (roughly 1/5 of previous 450)
|
|
9
9
|
DEFAULT_GRID_WIRE_WALL_LINES = int(DEFAULT_WALL_LINES * 0.7)
|
|
10
10
|
DEFAULT_SPARSE_WALL_DENSITY = 0.10
|
|
@@ -13,7 +13,7 @@ DEFAULT_STEEL_BEAM_CHANCE = 0.02
|
|
|
13
13
|
__all__ = [
|
|
14
14
|
"DEFAULT_GRID_COLS",
|
|
15
15
|
"DEFAULT_GRID_ROWS",
|
|
16
|
-
"
|
|
16
|
+
"DEFAULT_CELL_SIZE",
|
|
17
17
|
"DEFAULT_WALL_LINES",
|
|
18
18
|
"DEFAULT_GRID_WIRE_WALL_LINES",
|
|
19
19
|
"DEFAULT_SPARSE_WALL_DENSITY",
|
zombie_escape/locales/ui.en.json
CHANGED
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
"fonts": {
|
|
10
10
|
"primary": {
|
|
11
11
|
"resource": "assets/fonts/Silkscreen-Regular.ttf",
|
|
12
|
-
"scale": 0.75
|
|
12
|
+
"scale": 0.75,
|
|
13
|
+
"line_height_scale": 1.0
|
|
13
14
|
}
|
|
14
15
|
},
|
|
15
16
|
"menu": {
|
|
16
17
|
"settings": "Settings",
|
|
17
18
|
"quit": "Quit",
|
|
18
19
|
"locked_suffix": "[Locked]",
|
|
19
|
-
"window_hint": "Resize window: [ to shrink, ] to enlarge.\nF: toggle fullscreen.",
|
|
20
|
+
"window_hint": "Resize window: [ to shrink by one step (400x300), ] to enlarge by one step.\nF: toggle fullscreen.",
|
|
20
21
|
"seed_label": "Seed: %{value}",
|
|
21
22
|
"seed_hint": "Type 0-9 to set a custom seed, Backspace clears",
|
|
22
23
|
"seed_empty": "(auto)",
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"stage_select": "Stages",
|
|
25
26
|
"resources": "Resources"
|
|
26
27
|
},
|
|
27
|
-
"readme": "
|
|
28
|
+
"readme": "README/LICENSE",
|
|
28
29
|
"readme_stage6": "Open Stage 6+ Guide",
|
|
29
30
|
"hints": {
|
|
30
31
|
"navigate": "Up/Down: choose an option",
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"option_help": {
|
|
35
36
|
"settings": "Open Settings to adjust assists and localization.",
|
|
36
37
|
"quit": "Close the game.",
|
|
37
|
-
"readme": "Open the GitHub README in your browser.",
|
|
38
|
+
"readme": "Open the GitHub README in your browser. License information is also available there.",
|
|
38
39
|
"readme_stage6": "Open the Stage 6+ guide in your browser."
|
|
39
40
|
}
|
|
40
41
|
},
|
|
@@ -67,7 +68,8 @@
|
|
|
67
68
|
},
|
|
68
69
|
"stage5": {
|
|
69
70
|
"name": "#5 Survive Until Dawn",
|
|
70
|
-
"description": "No fuel—endure 20 minutes, then walk out when dawn arrives."
|
|
71
|
+
"description": "No fuel—endure 20 minutes, then walk out when dawn arrives.",
|
|
72
|
+
"intro": "(No fuel inside, it seems...\nGuess I'll have to\nsurvive until dawn.)"
|
|
71
73
|
},
|
|
72
74
|
"stage6": {
|
|
73
75
|
"name": "#6 Tracker Run",
|
|
@@ -94,6 +96,7 @@
|
|
|
94
96
|
"stage10": {
|
|
95
97
|
"name": "#10 Outbreak",
|
|
96
98
|
"description": "Zombies have infiltrated a building where survivors took shelter.",
|
|
99
|
+
"intro": "(A large open floor,\npacked with survivors...\nI have a bad feeling about this.)",
|
|
97
100
|
"survivor_conversion_messages": [
|
|
98
101
|
"It hurts!",
|
|
99
102
|
"I can't hold on!",
|
|
@@ -128,7 +131,8 @@
|
|
|
128
131
|
},
|
|
129
132
|
"stage15": {
|
|
130
133
|
"name": "#15 The Divide",
|
|
131
|
-
"description": "A central hazard splits the building. Cross with care."
|
|
134
|
+
"description": "A central hazard splits the building. Cross with care.",
|
|
135
|
+
"intro": "A maintenance hatch looms faintly\nabove the center of the floor."
|
|
132
136
|
},
|
|
133
137
|
"stage16": {
|
|
134
138
|
"name": "#16 Floor Collapse",
|
|
@@ -145,6 +149,11 @@
|
|
|
145
149
|
"stage19": {
|
|
146
150
|
"name": "#19 Corridors",
|
|
147
151
|
"description": "A floor of narrow corridors."
|
|
152
|
+
},
|
|
153
|
+
"stage20": {
|
|
154
|
+
"name": "#20 Rescue Request",
|
|
155
|
+
"description": "Link up and escape by car.",
|
|
156
|
+
"intro": "\"... \nThey should be inside.\nLink up with them.\""
|
|
148
157
|
}
|
|
149
158
|
},
|
|
150
159
|
"status": {
|
|
@@ -173,9 +182,9 @@
|
|
|
173
182
|
"find_fuel": "Find the fuel can",
|
|
174
183
|
"find_car": "Find the car",
|
|
175
184
|
"escape": "Escape the building",
|
|
185
|
+
"merge_buddy_single": "Meet up with your buddy",
|
|
186
|
+
"merge_buddy_multi": "Meet up with your crew (Joined: %{count}/%{limit})",
|
|
176
187
|
"pickup_buddy": "Pick up your buddy",
|
|
177
|
-
"find_buddy": "Find your buddy",
|
|
178
|
-
"escape_with_buddy": "Get your buddy in the car and escape",
|
|
179
188
|
"board_buddy": "Get your buddy in the car",
|
|
180
189
|
"buddy_onboard": "Buddy onboard: %{count}",
|
|
181
190
|
"escape_with_survivors": "Escape with the survivors aboard",
|
|
@@ -188,6 +197,7 @@
|
|
|
188
197
|
"menu": "Menu",
|
|
189
198
|
"localization": "Localization",
|
|
190
199
|
"player_support": "Player support",
|
|
200
|
+
"visual": "Visual",
|
|
191
201
|
"tougher_enemies": "Tougher enemies"
|
|
192
202
|
},
|
|
193
203
|
"rows": {
|
|
@@ -195,6 +205,7 @@
|
|
|
195
205
|
"language": "Language",
|
|
196
206
|
"footprints": "Footprints",
|
|
197
207
|
"car_hint": "Car hint",
|
|
208
|
+
"shadows": "Shadows",
|
|
198
209
|
"fast_zombies": "Fast zombies",
|
|
199
210
|
"steel_beams": "Steel beams"
|
|
200
211
|
},
|