zombie-escape 1.7.1__py3-none-any.whl → 1.10.0__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 (36) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/colors.py +41 -8
  3. zombie_escape/entities.py +376 -306
  4. zombie_escape/entities_constants.py +6 -0
  5. zombie_escape/gameplay/__init__.py +2 -2
  6. zombie_escape/gameplay/constants.py +1 -7
  7. zombie_escape/gameplay/footprints.py +2 -2
  8. zombie_escape/gameplay/interactions.py +4 -10
  9. zombie_escape/gameplay/layout.py +43 -4
  10. zombie_escape/gameplay/movement.py +45 -7
  11. zombie_escape/gameplay/spawn.py +283 -43
  12. zombie_escape/gameplay/state.py +19 -16
  13. zombie_escape/gameplay/survivors.py +47 -15
  14. zombie_escape/gameplay/utils.py +19 -1
  15. zombie_escape/input_utils.py +167 -0
  16. zombie_escape/level_blueprints.py +28 -0
  17. zombie_escape/locales/ui.en.json +55 -11
  18. zombie_escape/locales/ui.ja.json +54 -10
  19. zombie_escape/localization.py +28 -0
  20. zombie_escape/models.py +54 -7
  21. zombie_escape/render.py +704 -267
  22. zombie_escape/render_constants.py +12 -0
  23. zombie_escape/screens/__init__.py +1 -0
  24. zombie_escape/screens/game_over.py +8 -4
  25. zombie_escape/screens/gameplay.py +88 -41
  26. zombie_escape/screens/settings.py +124 -13
  27. zombie_escape/screens/title.py +111 -0
  28. zombie_escape/stage_constants.py +116 -3
  29. zombie_escape/world_grid.py +134 -0
  30. zombie_escape/zombie_escape.py +68 -61
  31. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/METADATA +11 -3
  32. zombie_escape-1.10.0.dist-info/RECORD +47 -0
  33. zombie_escape-1.7.1.dist-info/RECORD +0 -45
  34. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/WHEEL +0 -0
  35. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/entry_points.txt +0 -0
  36. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -14,6 +14,18 @@ from ..models import Stage
14
14
  from ..progress import load_progress
15
15
  from ..render import show_message
16
16
  from ..rng import generate_seed
17
+ from ..input_utils import (
18
+ CONTROLLER_BUTTON_DOWN,
19
+ CONTROLLER_BUTTON_DPAD_DOWN,
20
+ CONTROLLER_BUTTON_DPAD_LEFT,
21
+ CONTROLLER_BUTTON_DPAD_RIGHT,
22
+ CONTROLLER_BUTTON_DPAD_UP,
23
+ CONTROLLER_DEVICE_ADDED,
24
+ CONTROLLER_DEVICE_REMOVED,
25
+ init_first_controller,
26
+ init_first_joystick,
27
+ is_confirm_event,
28
+ )
17
29
  from ..screens import (
18
30
  ScreenID,
19
31
  ScreenTransition,
@@ -193,6 +205,8 @@ def title_screen(
193
205
  0,
194
206
  )
195
207
  selected = min(selected_stage_index, len(options) - 1)
208
+ controller = init_first_controller()
209
+ joystick = init_first_joystick() if controller is None else None
196
210
 
197
211
  while True:
198
212
  for event in pygame.event.get():
@@ -205,6 +219,22 @@ def title_screen(
205
219
  if event.type in (pygame.WINDOWSIZECHANGED, pygame.VIDEORESIZE):
206
220
  sync_window_size(event)
207
221
  continue
222
+ if event.type == pygame.JOYDEVICEADDED or (
223
+ CONTROLLER_DEVICE_ADDED is not None
224
+ and event.type == CONTROLLER_DEVICE_ADDED
225
+ ):
226
+ if controller is None:
227
+ controller = init_first_controller()
228
+ if controller is None:
229
+ joystick = init_first_joystick()
230
+ if event.type == pygame.JOYDEVICEREMOVED or (
231
+ CONTROLLER_DEVICE_REMOVED is not None
232
+ and event.type == CONTROLLER_DEVICE_REMOVED
233
+ ):
234
+ if controller and not controller.get_init():
235
+ controller = None
236
+ if joystick and not joystick.get_init():
237
+ joystick = None
208
238
  if event.type == pygame.KEYDOWN:
209
239
  if event.key == pygame.K_BACKSPACE:
210
240
  current_seed_text = _generate_auto_seed_text()
@@ -273,6 +303,87 @@ def title_screen(
273
303
  seed_text=current_seed_text,
274
304
  seed_is_auto=current_seed_auto,
275
305
  )
306
+ if event.type == pygame.JOYBUTTONDOWN or (
307
+ CONTROLLER_BUTTON_DOWN is not None
308
+ and event.type == CONTROLLER_BUTTON_DOWN
309
+ ):
310
+ if is_confirm_event(event):
311
+ current = options[selected]
312
+ if current["type"] == "stage" and current.get("available"):
313
+ seed_value = (
314
+ int(current_seed_text) if current_seed_text else None
315
+ )
316
+ return ScreenTransition(
317
+ ScreenID.GAMEPLAY,
318
+ stage=current["stage"],
319
+ seed=seed_value,
320
+ seed_text=current_seed_text,
321
+ seed_is_auto=current_seed_auto,
322
+ )
323
+ if current["type"] == "settings":
324
+ return ScreenTransition(
325
+ ScreenID.SETTINGS,
326
+ seed_text=current_seed_text,
327
+ seed_is_auto=current_seed_auto,
328
+ )
329
+ if current["type"] == "readme":
330
+ _open_readme_link()
331
+ continue
332
+ if current["type"] == "quit":
333
+ return ScreenTransition(
334
+ ScreenID.EXIT,
335
+ seed_text=current_seed_text,
336
+ seed_is_auto=current_seed_auto,
337
+ )
338
+ if CONTROLLER_BUTTON_DOWN is not None and event.type == CONTROLLER_BUTTON_DOWN:
339
+ if (
340
+ CONTROLLER_BUTTON_DPAD_UP is not None
341
+ and event.button == CONTROLLER_BUTTON_DPAD_UP
342
+ ):
343
+ selected = (selected - 1) % len(options)
344
+ if (
345
+ CONTROLLER_BUTTON_DPAD_DOWN is not None
346
+ and event.button == CONTROLLER_BUTTON_DPAD_DOWN
347
+ ):
348
+ selected = (selected + 1) % len(options)
349
+ if (
350
+ CONTROLLER_BUTTON_DPAD_LEFT is not None
351
+ and event.button == CONTROLLER_BUTTON_DPAD_LEFT
352
+ ):
353
+ if current_page > 0:
354
+ current_page -= 1
355
+ options, stage_options = _build_options(current_page)
356
+ selected = 0
357
+ if (
358
+ CONTROLLER_BUTTON_DPAD_RIGHT is not None
359
+ and event.button == CONTROLLER_BUTTON_DPAD_RIGHT
360
+ ):
361
+ if (
362
+ current_page < len(stage_pages) - 1
363
+ and _page_available(current_page + 1)
364
+ ):
365
+ current_page += 1
366
+ options, stage_options = _build_options(current_page)
367
+ selected = 0
368
+ if event.type == pygame.JOYHATMOTION:
369
+ hat_x, hat_y = event.value
370
+ if hat_y == 1:
371
+ selected = (selected - 1) % len(options)
372
+ elif hat_y == -1:
373
+ selected = (selected + 1) % len(options)
374
+ if hat_x == -1:
375
+ if current_page > 0:
376
+ current_page -= 1
377
+ options, stage_options = _build_options(current_page)
378
+ selected = 0
379
+ elif hat_x == 1:
380
+ if (
381
+ current_page < len(stage_pages) - 1
382
+ and _page_available(current_page + 1)
383
+ ):
384
+ current_page += 1
385
+ options, stage_options = _build_options(current_page)
386
+ selected = 0
276
387
 
277
388
  screen.fill(BLACK)
278
389
  title_text = tr("game.title")
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from .entities_constants import ZOMBIE_AGING_DURATION_FRAMES
6
6
  from .gameplay_constants import SURVIVOR_SPAWN_RATE
7
+ from .level_constants import DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS
7
8
  from .models import Stage
8
9
 
9
10
  STAGES: list[Stage] = [
@@ -23,6 +24,7 @@ STAGES: list[Stage] = [
23
24
  requires_fuel=True,
24
25
  exterior_spawn_weight=0.97,
25
26
  interior_spawn_weight=0.03,
27
+ initial_interior_spawn_rate=0.007,
26
28
  ),
27
29
  Stage(
28
30
  id="stage3",
@@ -33,6 +35,7 @@ STAGES: list[Stage] = [
33
35
  requires_fuel=True,
34
36
  exterior_spawn_weight=0.97,
35
37
  interior_spawn_weight=0.03,
38
+ initial_interior_spawn_rate=0.007,
36
39
  ),
37
40
  Stage(
38
41
  id="stage4",
@@ -42,6 +45,7 @@ STAGES: list[Stage] = [
42
45
  rescue_stage=True,
43
46
  waiting_car_target_count=2,
44
47
  survivor_spawn_rate=SURVIVOR_SPAWN_RATE,
48
+ initial_interior_spawn_rate=0.007,
45
49
  ),
46
50
  Stage(
47
51
  id="stage5",
@@ -49,8 +53,8 @@ STAGES: list[Stage] = [
49
53
  description_key="stages.stage5.description",
50
54
  available=True,
51
55
  requires_fuel=True,
52
- survival_stage=True,
53
- survival_goal_ms=1_200_000,
56
+ endurance_stage=True,
57
+ endurance_goal_ms=1_200_000,
54
58
  fuel_spawn_count=0,
55
59
  exterior_spawn_weight=0.4,
56
60
  interior_spawn_weight=0.6,
@@ -67,6 +71,7 @@ STAGES: list[Stage] = [
67
71
  zombie_normal_ratio=0.4,
68
72
  zombie_tracker_ratio=0.6,
69
73
  zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
74
+ initial_interior_spawn_rate=0.01,
70
75
  ),
71
76
  Stage(
72
77
  id="stage7",
@@ -82,6 +87,7 @@ STAGES: list[Stage] = [
82
87
  zombie_tracker_ratio=0.3,
83
88
  zombie_wall_follower_ratio=0.3,
84
89
  zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
90
+ initial_interior_spawn_rate=0.01,
85
91
  ),
86
92
  Stage(
87
93
  id="stage8",
@@ -97,20 +103,127 @@ STAGES: list[Stage] = [
97
103
  zombie_tracker_ratio=0.3,
98
104
  zombie_wall_follower_ratio=0.7,
99
105
  zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
106
+ initial_interior_spawn_rate=0.01,
100
107
  ),
101
108
  Stage(
102
109
  id="stage9",
103
110
  name_key="stages.stage9.name",
104
111
  description_key="stages.stage9.description",
105
- available=False,
112
+ available=True,
106
113
  rescue_stage=True,
107
114
  tile_size=35,
108
115
  requires_fuel=True,
109
116
  exterior_spawn_weight=0.4,
110
117
  interior_spawn_weight=0.6,
111
118
  waiting_car_target_count=1,
119
+ zombie_normal_ratio=0,
120
+ zombie_tracker_ratio=0.3,
121
+ zombie_wall_follower_ratio=0.7,
112
122
  survivor_spawn_rate=SURVIVOR_SPAWN_RATE,
113
123
  zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
124
+ initial_interior_spawn_rate=0.01,
125
+ ),
126
+ Stage(
127
+ id="stage10",
128
+ name_key="stages.stage10.name",
129
+ description_key="stages.stage10.description",
130
+ available=True,
131
+ rescue_stage=True,
132
+ tile_size=40,
133
+ wall_algorithm="sparse",
134
+ exterior_spawn_weight=0.7,
135
+ interior_spawn_weight=0.3,
136
+ zombie_normal_ratio=0.4,
137
+ zombie_tracker_ratio=0.4,
138
+ zombie_wall_follower_ratio=0.2,
139
+ zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
140
+ initial_interior_spawn_rate=0.02,
141
+ waiting_car_target_count=1,
142
+ survivor_spawn_rate=0.35,
143
+ ),
144
+ Stage(
145
+ id="stage11",
146
+ name_key="stages.stage11.name",
147
+ description_key="stages.stage11.description",
148
+ grid_cols=120,
149
+ grid_rows=7,
150
+ available=True,
151
+ wall_algorithm="sparse",
152
+ exterior_spawn_weight=0.3,
153
+ interior_spawn_weight=0.7,
154
+ zombie_normal_ratio=0.5,
155
+ zombie_tracker_ratio=0.5,
156
+ zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
157
+ initial_interior_spawn_rate=0.1,
158
+ waiting_car_target_count=1,
159
+ ),
160
+ Stage(
161
+ id="stage12",
162
+ name_key="stages.stage12.name",
163
+ description_key="stages.stage12.description",
164
+ grid_cols=32,
165
+ grid_rows=32,
166
+ available=True,
167
+ requires_fuel=True,
168
+ exterior_spawn_weight=0.5,
169
+ interior_spawn_weight=0.2,
170
+ interior_fall_spawn_weight=0.3,
171
+ fall_spawn_zones=[
172
+ (3, 3, 12, 12),
173
+ (3, 17, 12, 12),
174
+ (17, 3, 12, 12),
175
+ (17, 17, 12, 12),
176
+ ],
177
+ initial_flashlight_count=5,
178
+ zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
179
+ ),
180
+ Stage(
181
+ id="stage13",
182
+ name_key="stages.stage13.name",
183
+ description_key="stages.stage13.description",
184
+ available=True,
185
+ wall_algorithm="grid_wire",
186
+ buddy_required_count=1,
187
+ requires_fuel=True,
188
+ exterior_spawn_weight=0.6,
189
+ interior_spawn_weight=0.1,
190
+ interior_fall_spawn_weight=0.3,
191
+ zombie_normal_ratio=0.4,
192
+ zombie_tracker_ratio=0.3,
193
+ zombie_wall_follower_ratio=0.3,
194
+ zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
195
+ initial_flashlight_count=3,
196
+ fall_spawn_zones=[
197
+ (x, y, 2, 2)
198
+ for y in range(2, DEFAULT_GRID_ROWS - 2, 4)
199
+ for x in range(2, DEFAULT_GRID_COLS - 2, 4)
200
+ ],
201
+ ),
202
+ Stage(
203
+ id="stage14",
204
+ name_key="stages.stage14.name",
205
+ description_key="stages.stage14.description",
206
+ grid_cols=42,
207
+ grid_rows=27,
208
+ available=False,
209
+ requires_fuel=True,
210
+ exterior_spawn_weight=0.2,
211
+ interior_spawn_weight=0.1,
212
+ interior_fall_spawn_weight=0.7,
213
+ fall_spawn_zones=[
214
+ (4, 10, 3, 3),
215
+ (5, 20, 3, 3),
216
+ (15, 17, 3, 3),
217
+ (22, 16, 3, 3),
218
+ (17, 20, 3, 3),
219
+ (26, 22, 3, 3),
220
+ (31, 17, 3, 3),
221
+ (33, 10, 3, 3),
222
+ (34, 7, 3, 3),
223
+ (35, 13, 3, 3),
224
+ ],
225
+ initial_flashlight_count=5,
226
+ zombie_aging_duration_frames=ZOMBIE_AGING_DURATION_FRAMES * 2,
114
227
  ),
115
228
  ]
116
229
  DEFAULT_STAGE_ID = "stage1"
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Iterable, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from .entities import Wall
8
+
9
+
10
+ WallIndex = dict[tuple[int, int], list["Wall"]]
11
+
12
+
13
+ def build_wall_index(walls: Iterable["Wall"], *, cell_size: int) -> WallIndex:
14
+ index: WallIndex = {}
15
+ if cell_size <= 0:
16
+ return index
17
+ for wall in walls:
18
+ cell_x = int(wall.rect.centerx // cell_size)
19
+ cell_y = int(wall.rect.centery // cell_size)
20
+ index.setdefault((cell_x, cell_y), []).append(wall)
21
+ return index
22
+
23
+
24
+ def _infer_grid_size_from_index(wall_index: WallIndex) -> tuple[int | None, int | None]:
25
+ if not wall_index:
26
+ return None, None
27
+ max_col = max(cell[0] for cell in wall_index)
28
+ max_row = max(cell[1] for cell in wall_index)
29
+ return max_col + 1, max_row + 1
30
+
31
+
32
+ def walls_for_radius(
33
+ wall_index: WallIndex,
34
+ center: tuple[float, float],
35
+ radius: float,
36
+ *,
37
+ cell_size: int,
38
+ grid_cols: int | None = None,
39
+ grid_rows: int | None = None,
40
+ ) -> list["Wall"]:
41
+ if grid_cols is None or grid_rows is None:
42
+ grid_cols, grid_rows = _infer_grid_size_from_index(wall_index)
43
+ if grid_cols is None or grid_rows is None:
44
+ return []
45
+ search_radius = radius + cell_size
46
+ min_x = max(0, int((center[0] - search_radius) // cell_size))
47
+ max_x = min(grid_cols - 1, int((center[0] + search_radius) // cell_size))
48
+ min_y = max(0, int((center[1] - search_radius) // cell_size))
49
+ max_y = min(grid_rows - 1, int((center[1] + search_radius) // cell_size))
50
+ candidates: list[Wall] = []
51
+ for cy in range(min_y, max_y + 1):
52
+ for cx in range(min_x, max_x + 1):
53
+ candidates.extend(wall_index.get((cx, cy), []))
54
+ return candidates
55
+
56
+
57
+ def apply_tile_edge_nudge(
58
+ x: float,
59
+ y: float,
60
+ dx: float,
61
+ dy: float,
62
+ *,
63
+ cell_size: int,
64
+ wall_cells: set[tuple[int, int]] | None,
65
+ bevel_corners: dict[tuple[int, int], tuple[bool, bool, bool, bool]] | None = None,
66
+ grid_cols: int,
67
+ grid_rows: int,
68
+ strength: float = 0.03,
69
+ edge_margin_ratio: float = 0.15,
70
+ min_margin: float = 2.0,
71
+ ) -> tuple[float, float]:
72
+ if dx == 0 and dy == 0:
73
+ return dx, dy
74
+ if cell_size <= 0 or not wall_cells:
75
+ return dx, dy
76
+ cell_x = int(x // cell_size)
77
+ cell_y = int(y // cell_size)
78
+ if cell_x < 0 or cell_y < 0 or cell_x >= grid_cols or cell_y >= grid_rows:
79
+ return dx, dy
80
+ speed = math.hypot(dx, dy)
81
+ if speed <= 0:
82
+ return dx, dy
83
+
84
+ edge_margin = max(min_margin, cell_size * edge_margin_ratio)
85
+ left_dist = x - (cell_x * cell_size)
86
+ right_dist = ((cell_x + 1) * cell_size) - x
87
+ top_dist = y - (cell_y * cell_size)
88
+ bottom_dist = ((cell_y + 1) * cell_size) - y
89
+
90
+ def apply_push(dist: float, direction: float) -> float:
91
+ if dist >= edge_margin:
92
+ return 0.0
93
+ ratio = (edge_margin - dist) / edge_margin
94
+ return ratio * speed * strength * direction
95
+
96
+ if (cell_x - 1, cell_y) in wall_cells:
97
+ dx += apply_push(left_dist, 1.0)
98
+ if (cell_x + 1, cell_y) in wall_cells:
99
+ dx += apply_push(right_dist, -1.0)
100
+ if (cell_x, cell_y - 1) in wall_cells:
101
+ dy += apply_push(top_dist, 1.0)
102
+ if (cell_x, cell_y + 1) in wall_cells:
103
+ dy += apply_push(bottom_dist, -1.0)
104
+
105
+ def apply_corner_push(dist_a: float, dist_b: float, boost: float = 1.0) -> float:
106
+ if dist_a >= edge_margin or dist_b >= edge_margin:
107
+ return 0.0
108
+ ratio = (edge_margin - min(dist_a, dist_b)) / edge_margin
109
+ return ratio * speed * strength * boost
110
+
111
+ if bevel_corners:
112
+ boosted = 1.25
113
+ corner_wall = bevel_corners.get((cell_x - 1, cell_y - 1))
114
+ if corner_wall and corner_wall[2]:
115
+ push = apply_corner_push(left_dist, top_dist, boosted)
116
+ dx += push
117
+ dy += push
118
+ corner_wall = bevel_corners.get((cell_x + 1, cell_y - 1))
119
+ if corner_wall and corner_wall[3]:
120
+ push = apply_corner_push(right_dist, top_dist, boosted)
121
+ dx -= push
122
+ dy += push
123
+ corner_wall = bevel_corners.get((cell_x + 1, cell_y + 1))
124
+ if corner_wall and corner_wall[0]:
125
+ push = apply_corner_push(right_dist, bottom_dist, boosted)
126
+ dx -= push
127
+ dy -= push
128
+ corner_wall = bevel_corners.get((cell_x - 1, cell_y + 1))
129
+ if corner_wall and corner_wall[1]:
130
+ push = apply_corner_push(left_dist, bottom_dist, boosted)
131
+ dx += push
132
+ dy -= push
133
+
134
+ return dx, dy
@@ -22,7 +22,7 @@ from .gameplay import calculate_car_speed_for_passengers
22
22
  from .level_constants import DEFAULT_TILE_SIZE
23
23
  from .localization import set_language
24
24
  from .models import GameData, Stage
25
- from .render_constants import build_render_assets
25
+ from .render_constants import RenderAssets, build_render_assets
26
26
  from .screen_constants import (
27
27
  DEFAULT_WINDOW_SCALE,
28
28
  FPS,
@@ -83,6 +83,9 @@ def main() -> None:
83
83
  sys.argv = [sys.argv[0]] + remaining
84
84
 
85
85
  pygame.init()
86
+ pygame.joystick.init()
87
+ if hasattr(pygame, "controller"):
88
+ pygame.controller.init()
86
89
  try:
87
90
  pygame.font.init()
88
91
  except pygame.error as e:
@@ -92,6 +95,7 @@ def main() -> None:
92
95
  from .screens.gameplay import gameplay_screen
93
96
 
94
97
  apply_window_scale(DEFAULT_WINDOW_SCALE)
98
+ pygame.mouse.set_visible(True)
95
99
  screen = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)).convert_alpha()
96
100
  clock = pygame.time.Clock()
97
101
 
@@ -106,15 +110,54 @@ def main() -> None:
106
110
  save_config(config, config_path)
107
111
  set_language(config.get("language"))
108
112
 
113
+ def _profiled_gameplay_screen(
114
+ screen: pygame.Surface,
115
+ clock: pygame.time.Clock,
116
+ config: dict[str, Any],
117
+ fps: int,
118
+ stage: Stage,
119
+ *,
120
+ show_pause_overlay: bool,
121
+ seed: int | None,
122
+ render_assets: RenderAssets,
123
+ debug_mode: bool,
124
+ ) -> ScreenTransition:
125
+ import cProfile
126
+ import pstats
127
+
128
+ profiler = cProfile.Profile()
129
+ try:
130
+ return profiler.runcall(
131
+ gameplay_screen,
132
+ screen,
133
+ clock,
134
+ config,
135
+ fps,
136
+ stage,
137
+ show_pause_overlay=show_pause_overlay,
138
+ seed=seed,
139
+ render_assets=render_assets,
140
+ debug_mode=debug_mode,
141
+ )
142
+ finally:
143
+ output_path = Path(args.profile_output)
144
+ profiler.dump_stats(output_path)
145
+ summary_path = output_path.with_suffix(".txt")
146
+ with summary_path.open("w", encoding="utf-8") as handle:
147
+ stats = pstats.Stats(
148
+ profiler,
149
+ stream=handle,
150
+ ).sort_stats("tottime")
151
+ stats.print_stats(50)
152
+ print(f"Profile saved to {output_path} and {summary_path}")
153
+
109
154
  next_screen = ScreenID.TITLE
110
- pending_stage: Stage | None = None
111
- pending_game_data: GameData | None = None
112
- pending_config: dict[str, Any] | None = None
113
- pending_seed: int | None = None
155
+ transition: ScreenTransition | None = None
114
156
  running = True
115
157
 
116
158
  while running:
117
- transition: ScreenTransition | None = None
159
+ incoming = transition
160
+ transition = None
118
161
 
119
162
  if next_screen == ScreenID.TITLE:
120
163
  seed_input = None if title_seed_is_auto else title_seed_text
@@ -144,56 +187,26 @@ def main() -> None:
144
187
  set_language(config.get("language"))
145
188
  transition = ScreenTransition(ScreenID.TITLE)
146
189
  elif next_screen == ScreenID.GAMEPLAY:
147
- stage = pending_stage
148
- pending_stage = None
149
- seed_value = pending_seed
150
- pending_seed = None
190
+ stage = incoming.stage
191
+ seed_value = incoming.seed
151
192
  if stage is None:
152
193
  transition = ScreenTransition(ScreenID.TITLE)
153
194
  else:
154
195
  last_stage_id = stage.id
196
+ render_assets = build_render_assets(stage.tile_size)
155
197
  try:
156
- if args.profile:
157
- import cProfile
158
- import pstats
159
-
160
- profiler = cProfile.Profile()
161
- try:
162
- render_assets = build_render_assets(stage.tile_size)
163
- transition = profiler.runcall(
164
- gameplay_screen,
165
- screen,
166
- clock,
167
- config,
168
- FPS,
169
- stage,
170
- show_pause_overlay=not debug_mode,
171
- seed=seed_value,
172
- render_assets=render_assets,
173
- debug_mode=debug_mode,
174
- )
175
- finally:
176
- output_path = Path(args.profile_output)
177
- profiler.dump_stats(output_path)
178
- summary_path = output_path.with_suffix(".txt")
179
- stats = pstats.Stats(profiler).sort_stats("tottime")
180
- with summary_path.open("w", encoding="utf-8") as handle:
181
- stats.stream = handle
182
- stats.print_stats(50)
183
- print(f"Profile saved to {output_path} and {summary_path}")
184
- else:
185
- render_assets = build_render_assets(stage.tile_size)
186
- transition = gameplay_screen(
187
- screen,
188
- clock,
189
- config,
190
- FPS,
191
- stage,
192
- show_pause_overlay=not debug_mode,
193
- seed=seed_value,
194
- render_assets=render_assets,
195
- debug_mode=debug_mode,
196
- )
198
+ gs = _profiled_gameplay_screen if args.profile else gameplay_screen
199
+ transition = gs(
200
+ screen,
201
+ clock,
202
+ config,
203
+ FPS,
204
+ stage,
205
+ show_pause_overlay=not debug_mode,
206
+ seed=seed_value,
207
+ render_assets=render_assets,
208
+ debug_mode=debug_mode,
209
+ )
197
210
  except SystemExit:
198
211
  running = False
199
212
  break
@@ -203,12 +216,10 @@ def main() -> None:
203
216
  running = False
204
217
  break
205
218
  elif next_screen == ScreenID.GAME_OVER:
206
- game_data = pending_game_data
207
- stage = pending_stage
208
- config_payload = pending_config
209
- pending_game_data = None
210
- pending_stage = None
211
- pending_config = None
219
+ game_data = incoming.game_data if incoming else None
220
+ stage = incoming.stage if incoming else None
221
+ config_payload = incoming.config if incoming else None
222
+ assert config_payload is not None
212
223
  if game_data is not None:
213
224
  render_assets = build_render_assets(game_data.cell_size)
214
225
  elif stage is not None:
@@ -232,10 +243,6 @@ def main() -> None:
232
243
  if not transition:
233
244
  break
234
245
 
235
- pending_stage = transition.stage
236
- pending_game_data = transition.game_data
237
- pending_config = transition.config
238
- pending_seed = transition.seed
239
246
  if transition.next_screen == ScreenID.GAMEPLAY:
240
247
  title_seed_text = cli_seed_text
241
248
  title_seed_is_auto = cli_seed_is_auto
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zombie-escape
3
- Version: 1.7.1
3
+ Version: 1.10.0
4
4
  Summary: Top-down zombie survival game built with pygame.
5
5
  Project-URL: Homepage, https://github.com/tos-kamiya/zombie-escape
6
6
  Author-email: Toshihiro Kamiya <kamiya@mbj.nifty.com>
@@ -12,12 +12,19 @@ Classifier: Programming Language :: Python
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
15
16
  Classifier: Programming Language :: Python :: Implementation :: CPython
16
17
  Requires-Python: >=3.10
17
18
  Requires-Dist: numpy
18
19
  Requires-Dist: platformdirs
19
20
  Requires-Dist: pygame
20
21
  Requires-Dist: python-i18n
22
+ Requires-Dist: typing-extensions; python_version < '3.11'
23
+ Provides-Extra: dev
24
+ Requires-Dist: pydeps; extra == 'dev'
25
+ Requires-Dist: pyright; extra == 'dev'
26
+ Requires-Dist: pytest; extra == 'dev'
27
+ Requires-Dist: ruff; extra == 'dev'
21
28
  Description-Content-Type: text/markdown
22
29
 
23
30
  # Zombie Escape
@@ -46,10 +53,11 @@ This game is a simple 2D top-down action game where the player aims to escape by
46
53
 
47
54
  - **Player/Car Movement:** `W` / `↑` (Up), `A` / `←` (Left), `S` / `↓` (Down), `D` / `→` (Right)
48
55
  - **Enter Car:** Overlap the player with the car.
49
- - **Quit Game:** `ESC` key
56
+ - **Pause:** `P`/Start or `ESC`/Select
57
+ - **Quit Game:** `ESC`/Select (from pause)
50
58
  - **Restart:** `R` key (on Game Over/Clear screen)
51
59
  - **Window/Fullscreen (title/settings only):** `[` to shrink, `]` to enlarge, `F` to toggle fullscreen
52
- - **Time Acceleration:** Hold either `Shift` key to run the entire world 4x faster; release to return to normal speed.
60
+ - **Time Acceleration:** Hold either `Shift` key or `R1` to run the entire world 4x faster; release to return to normal speed.
53
61
 
54
62
  ## Title Screen
55
63