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.
Files changed (42) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/config.py +1 -0
  3. zombie_escape/entities.py +126 -199
  4. zombie_escape/entities_constants.py +11 -1
  5. zombie_escape/export_images.py +4 -4
  6. zombie_escape/font_utils.py +47 -0
  7. zombie_escape/gameplay/__init__.py +2 -1
  8. zombie_escape/gameplay/constants.py +4 -0
  9. zombie_escape/gameplay/interactions.py +83 -16
  10. zombie_escape/gameplay/layout.py +9 -15
  11. zombie_escape/gameplay/movement.py +45 -29
  12. zombie_escape/gameplay/spawn.py +15 -29
  13. zombie_escape/gameplay/state.py +62 -7
  14. zombie_escape/gameplay/survivors.py +61 -10
  15. zombie_escape/gameplay/utils.py +33 -0
  16. zombie_escape/level_blueprints.py +35 -31
  17. zombie_escape/level_constants.py +2 -2
  18. zombie_escape/locales/ui.en.json +19 -8
  19. zombie_escape/locales/ui.ja.json +19 -8
  20. zombie_escape/localization.py +7 -1
  21. zombie_escape/models.py +21 -6
  22. zombie_escape/render/__init__.py +2 -2
  23. zombie_escape/render/core.py +113 -81
  24. zombie_escape/render/hud.py +112 -40
  25. zombie_escape/render/overview.py +93 -2
  26. zombie_escape/render/shadows.py +2 -2
  27. zombie_escape/render_constants.py +12 -0
  28. zombie_escape/screens/__init__.py +6 -189
  29. zombie_escape/screens/game_over.py +8 -21
  30. zombie_escape/screens/gameplay.py +71 -26
  31. zombie_escape/screens/settings.py +114 -43
  32. zombie_escape/screens/title.py +128 -47
  33. zombie_escape/stage_constants.py +37 -8
  34. zombie_escape/windowing.py +508 -0
  35. zombie_escape/world_grid.py +7 -5
  36. zombie_escape/zombie_escape.py +26 -13
  37. {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/METADATA +24 -24
  38. zombie_escape-1.15.2.dist-info/RECORD +54 -0
  39. zombie_escape-1.14.4.dist-info/RECORD +0 -53
  40. {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/WHEEL +0 -0
  41. {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/entry_points.txt +0 -0
  42. {zombie_escape-1.14.4.dist-info → zombie_escape-1.15.2.dist-info}/licenses/LICENSE.txt +0 -0
@@ -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 ..models import GameData, Groups, LevelLayout, ProgressState, Stage
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
- game_over_message=None,
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.tile_size
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
- level_width=level_width,
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
- wall_cells=game_data.layout.wall_cells,
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
- if not rect_visible_on_screen(camera, survivor.rect):
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,
@@ -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 tiles if valid, otherwise None.
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
- passable_tiles = set()
35
- exit_tiles = set()
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
- passable_tiles.add((x, y))
49
+ if ch not in ("x", "B", "O"):
50
+ passable_cells.add((x, y))
44
51
  if ch == "E":
45
- exit_tiles.add((x, y))
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 passable_tiles
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 passable_tiles and (nx, ny) not in reachable:
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 exit_tiles and not any(e in reachable for e in exit_tiles):
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 tiles are reachable by Humans (8-way BFS with jumps)."""
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
- passable_tiles = set()
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
- passable_tiles.add((x, y))
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 passable_tiles and (nx, ny) not in reachable:
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(passable_tiles) == len(reachable)
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 tiles if both pass, otherwise None.
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] in (".", "Z"):
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] in (".", "Z"):
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 tiles at a low density, avoiding adjacency."""
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 tiles at a low density, avoiding orthogonal adjacency."""
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 tiles with pitfalls based on density."""
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
- ) -> dict:
478
+ ) -> Blueprint:
472
479
  grid = _init_grid(cols, rows)
473
480
  _place_exits(grid, EXITS_PER_SIDE)
474
481
 
475
- # Spawns: player, car, zombies
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
- for _ in range(SPAWN_ZOMBIES):
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 {"grid": blueprint_rows, "steel_cells": steel_beams}
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
- ) -> dict:
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["grid"])
631
+ car_reachable = validate_connectivity(blueprint.grid)
628
632
  if car_reachable is not None:
629
- blueprint["car_reachable_cells"] = car_reachable
633
+ blueprint.car_reachable_cells = car_reachable
630
634
  return blueprint
631
635
 
632
636
  raise MapGenerationError("Connectivity validation failed after 20 attempts")
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  DEFAULT_GRID_COLS = 48
6
6
  DEFAULT_GRID_ROWS = 30
7
- DEFAULT_TILE_SIZE = 50 # world units per cell; adjust to scale the whole map
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
- "DEFAULT_TILE_SIZE",
16
+ "DEFAULT_CELL_SIZE",
17
17
  "DEFAULT_WALL_LINES",
18
18
  "DEFAULT_GRID_WIRE_WALL_LINES",
19
19
  "DEFAULT_SPARSE_WALL_DENSITY",
@@ -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": "Open 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
  },