zombie-escape 1.2.1__tar.gz → 1.3.1__tar.gz

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 (30) hide show
  1. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/PKG-INFO +5 -1
  2. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/README.md +4 -0
  3. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/__about__.py +1 -1
  4. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/entities.py +16 -14
  5. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/gameplay/logic.py +22 -21
  6. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/level_blueprints.py +9 -7
  7. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/locales/ui.en.json +6 -2
  8. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/locales/ui.ja.json +6 -2
  9. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/models.py +1 -0
  10. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/render.py +9 -1
  11. zombie_escape-1.3.1/src/zombie_escape/rng.py +132 -0
  12. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/screens/__init__.py +3 -0
  13. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/screens/game_over.py +15 -1
  14. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/screens/gameplay.py +6 -0
  15. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/screens/title.py +61 -5
  16. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/zombie_escape.py +40 -3
  17. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/.gitignore +0 -0
  18. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/LICENSE.txt +0 -0
  19. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/pyproject.toml +0 -0
  20. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/__init__.py +0 -0
  21. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/assets/fonts/Silkscreen-Regular.ttf +0 -0
  22. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/assets/fonts/misaki_gothic.ttf +0 -0
  23. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/colors.py +0 -0
  24. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/config.py +0 -0
  25. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/constants.py +0 -0
  26. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/font_utils.py +0 -0
  27. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/gameplay/__init__.py +0 -0
  28. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/localization.py +0 -0
  29. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/render_assets.py +0 -0
  30. {zombie_escape-1.2.1 → zombie_escape-1.3.1}/src/zombie_escape/screens/settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zombie-escape
3
- Version: 1.2.1
3
+ Version: 1.3.1
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>
@@ -59,6 +59,10 @@ Open **Settings** from the title to toggle gameplay assists:
59
59
  - **Flashlight pickups:** Enable flashlight spawns that expand your visible radius when collected.
60
60
  - **Steel beams:** Adds tougher single-cell obstacles (5% density) that block movement; hidden when stacked with an inner wall until that wall is destroyed.
61
61
 
62
+ ### Shared Seeds
63
+
64
+ The title screen also lets you enter a numeric **seed**. Type digits (or pass `--seed <number>` on the CLI) to lock the procedural layout, wall placement, and pickups; share that seed with a friend and you will both play the exact same stage even on different machines. The current seed is shown at the bottom right of the title screen and in-game HUD. Backspace reverts to an automatically generated value so you can quickly roll a fresh challenge.
65
+
62
66
  ## Game Rules
63
67
 
64
68
  ### Stages
@@ -38,6 +38,10 @@ Open **Settings** from the title to toggle gameplay assists:
38
38
  - **Flashlight pickups:** Enable flashlight spawns that expand your visible radius when collected.
39
39
  - **Steel beams:** Adds tougher single-cell obstacles (5% density) that block movement; hidden when stacked with an inner wall until that wall is destroyed.
40
40
 
41
+ ### Shared Seeds
42
+
43
+ The title screen also lets you enter a numeric **seed**. Type digits (or pass `--seed <number>` on the CLI) to lock the procedural layout, wall placement, and pickups; share that seed with a friend and you will both play the exact same stage even on different machines. The current seed is shown at the bottom right of the title screen and in-game HUD. Backspace reverts to an automatically generated value so you can quickly roll a fresh challenge.
44
+
41
45
  ## Game Rules
42
46
 
43
47
  ### Stages
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "1.2.1"
4
+ __version__ = "1.3.1"
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import math
6
- import random
7
6
  from enum import Enum
8
7
  from typing import Callable, Iterable, Self
9
8
 
@@ -55,6 +54,9 @@ from .constants import (
55
54
  ZOMBIE_SIGHT_RANGE,
56
55
  ZOMBIE_SPEED,
57
56
  )
57
+ from .rng import get_rng
58
+
59
+ RNG = get_rng()
58
60
 
59
61
 
60
62
  # --- Camera Class ---
@@ -357,16 +359,16 @@ class Survivor(pygame.sprite.Sprite):
357
359
 
358
360
 
359
361
  def random_position_outside_building() -> tuple[int, int]:
360
- side = random.choice(["top", "bottom", "left", "right"])
362
+ side = RNG.choice(["top", "bottom", "left", "right"])
361
363
  margin = 0
362
364
  if side == "top":
363
- x, y = random.randint(0, LEVEL_WIDTH), -margin
365
+ x, y = RNG.randint(0, LEVEL_WIDTH), -margin
364
366
  elif side == "bottom":
365
- x, y = random.randint(0, LEVEL_WIDTH), LEVEL_HEIGHT + margin
367
+ x, y = RNG.randint(0, LEVEL_WIDTH), LEVEL_HEIGHT + margin
366
368
  elif side == "left":
367
- x, y = -margin, random.randint(0, LEVEL_HEIGHT)
369
+ x, y = -margin, RNG.randint(0, LEVEL_HEIGHT)
368
370
  else:
369
- x, y = LEVEL_WIDTH + margin, random.randint(0, LEVEL_HEIGHT)
371
+ x, y = LEVEL_WIDTH + margin, RNG.randint(0, LEVEL_HEIGHT)
370
372
  return x, y
371
373
 
372
374
 
@@ -398,12 +400,12 @@ class Zombie(pygame.sprite.Sprite):
398
400
  if speed > ZOMBIE_SPEED
399
401
  else NORMAL_ZOMBIE_SPEED_JITTER
400
402
  )
401
- self.speed = speed + random.uniform(-jitter, jitter)
403
+ self.speed = speed + RNG.uniform(-jitter, jitter)
402
404
  self.x = float(self.rect.centerx)
403
405
  self.y = float(self.rect.centery)
404
- self.mode = random.choice(list(ZombieMode))
406
+ self.mode = RNG.choice(list(ZombieMode))
405
407
  self.last_mode_change_time = pygame.time.get_ticks()
406
- self.mode_change_interval = ZOMBIE_MODE_CHANGE_INTERVAL_MS + random.randint(
408
+ self.mode_change_interval = ZOMBIE_MODE_CHANGE_INTERVAL_MS + RNG.randint(
407
409
  -1000, 1000
408
410
  )
409
411
  self.was_in_sight = False
@@ -413,9 +415,9 @@ class Zombie(pygame.sprite.Sprite):
413
415
  self.mode = force_mode
414
416
  else:
415
417
  possible_modes = list(ZombieMode)
416
- self.mode = random.choice(possible_modes)
418
+ self.mode = RNG.choice(possible_modes)
417
419
  self.last_mode_change_time = pygame.time.get_ticks()
418
- self.mode_change_interval = ZOMBIE_MODE_CHANGE_INTERVAL_MS + random.randint(
420
+ self.mode_change_interval = ZOMBIE_MODE_CHANGE_INTERVAL_MS + RNG.randint(
419
421
  -1000, 1000
420
422
  )
421
423
 
@@ -439,9 +441,9 @@ class Zombie(pygame.sprite.Sprite):
439
441
  * self.speed
440
442
  * 0.8
441
443
  )
442
- move_y = random.uniform(-self.speed * 0.6, self.speed * 0.6)
444
+ move_y = RNG.uniform(-self.speed * 0.6, self.speed * 0.6)
443
445
  elif self.mode == ZombieMode.FLANK_Y:
444
- move_x = random.uniform(-self.speed * 0.6, self.speed * 0.6)
446
+ move_x = RNG.uniform(-self.speed * 0.6, self.speed * 0.6)
445
447
  if dist > 0:
446
448
  move_y = (
447
449
  (dy_target / abs(dy_target) if dy_target != 0 else 0)
@@ -512,7 +514,7 @@ class Zombie(pygame.sprite.Sprite):
512
514
  away_dy = next_y - closest.y
513
515
  away_dist = math.hypot(away_dx, away_dy)
514
516
  if away_dist == 0:
515
- angle = random.uniform(0, 2 * math.pi)
517
+ angle = RNG.uniform(0, 2 * math.pi)
516
518
  away_dx, away_dy = math.cos(angle), math.sin(angle)
517
519
  away_dist = 1
518
520
 
@@ -4,7 +4,6 @@ from bisect import bisect_left
4
4
  from typing import Any, Mapping, Sequence
5
5
 
6
6
  import math
7
- import random
8
7
 
9
8
  import pygame
10
9
 
@@ -56,6 +55,7 @@ from ..constants import (
56
55
  from ..localization import translate as _
57
56
  from ..level_blueprints import choose_blueprint
58
57
  from ..models import Areas, GameData, Groups, ProgressState, Stage
58
+ from ..rng import get_rng
59
59
  from ..entities import (
60
60
  Camera,
61
61
  Car,
@@ -70,6 +70,7 @@ from ..entities import (
70
70
  )
71
71
 
72
72
  LOGICAL_SCREEN_RECT = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
73
+ RNG = get_rng()
73
74
 
74
75
  __all__ = [
75
76
  "create_zombie",
@@ -118,7 +119,7 @@ def create_zombie(
118
119
  fast_conf = config.get("fast_zombies", {})
119
120
  fast_enabled = fast_conf.get("enabled", True)
120
121
  if fast_enabled:
121
- base_speed = random.uniform(ZOMBIE_SPEED, FAST_ZOMBIE_BASE_SPEED)
122
+ base_speed = RNG.uniform(ZOMBIE_SPEED, FAST_ZOMBIE_BASE_SPEED)
122
123
  else:
123
124
  base_speed = ZOMBIE_SPEED
124
125
  base_speed = min(base_speed, PLAYER_SPEED - 0.05)
@@ -263,7 +264,7 @@ def place_new_car(
263
264
 
264
265
  max_attempts = 150
265
266
  for attempt in range(max_attempts):
266
- cell = random.choice(walkable_cells)
267
+ cell = RNG.choice(walkable_cells)
267
268
  c_x, c_y = cell.center
268
269
  temp_car = Car(c_x, c_y)
269
270
  temp_rect = temp_car.rect.inflate(30, 30)
@@ -305,7 +306,7 @@ def place_fuel_can(
305
306
  min_car_dist = 200
306
307
 
307
308
  for attempt in range(200):
308
- cell = random.choice(walkable_cells)
309
+ cell = RNG.choice(walkable_cells)
309
310
  if (
310
311
  math.hypot(cell.centerx - player.x, cell.centery - player.y)
311
312
  < min_player_dist
@@ -325,7 +326,7 @@ def place_fuel_can(
325
326
  return FuelCan(cell.centerx, cell.centery)
326
327
 
327
328
  # Fallback: drop near a random walkable cell
328
- cell = random.choice(walkable_cells)
329
+ cell = RNG.choice(walkable_cells)
329
330
  return FuelCan(cell.centerx, cell.centery)
330
331
 
331
332
 
@@ -343,7 +344,7 @@ def place_flashlight(
343
344
  min_car_dist = 200
344
345
 
345
346
  for attempt in range(200):
346
- cell = random.choice(walkable_cells)
347
+ cell = RNG.choice(walkable_cells)
347
348
  if (
348
349
  math.hypot(cell.centerx - player.x, cell.centery - player.y)
349
350
  < min_player_dist
@@ -361,7 +362,7 @@ def place_flashlight(
361
362
  continue
362
363
  return Flashlight(cell.centerx, cell.centery)
363
364
 
364
- cell = random.choice(walkable_cells)
365
+ cell = RNG.choice(walkable_cells)
365
366
  return Flashlight(cell.centerx, cell.centery)
366
367
 
367
368
 
@@ -409,7 +410,7 @@ def place_companion(
409
410
  min_car_dist = 180
410
411
 
411
412
  for attempt in range(200):
412
- cell = random.choice(walkable_cells)
413
+ cell = RNG.choice(walkable_cells)
413
414
  if (
414
415
  math.hypot(cell.centerx - player.x, cell.centery - player.y)
415
416
  < min_player_dist
@@ -427,7 +428,7 @@ def place_companion(
427
428
  continue
428
429
  return Companion(cell.centerx, cell.centery)
429
430
 
430
- cell = random.choice(walkable_cells)
431
+ cell = RNG.choice(walkable_cells)
431
432
  return Companion(cell.centerx, cell.centery)
432
433
 
433
434
 
@@ -443,10 +444,10 @@ def scatter_positions_on_walkable(
443
444
 
444
445
  clamped_rate = max(0.0, min(1.0, spawn_rate))
445
446
  for cell in walkable_cells:
446
- if random.random() >= clamped_rate:
447
+ if RNG.random() >= clamped_rate:
447
448
  continue
448
- jitter_x = random.uniform(-cell.width * jitter_ratio, cell.width * jitter_ratio)
449
- jitter_y = random.uniform(
449
+ jitter_x = RNG.uniform(-cell.width * jitter_ratio, cell.width * jitter_ratio)
450
+ jitter_y = RNG.uniform(
450
451
  -cell.height * jitter_ratio, cell.height * jitter_ratio
451
452
  )
452
453
  positions.append((int(cell.centerx + jitter_x), int(cell.centery + jitter_y)))
@@ -500,7 +501,7 @@ def update_survivors(game_data: GameData) -> None:
500
501
  dy = point[1] - survivor.y
501
502
  dist = math.hypot(dx, dy)
502
503
  if dist == 0:
503
- angle = random.uniform(0, math.tau)
504
+ angle = RNG.uniform(0, math.tau)
504
505
  dx, dy = math.cos(angle), math.sin(angle)
505
506
  dist = 1
506
507
  if dist < min_dist:
@@ -527,7 +528,7 @@ def update_survivors(game_data: GameData) -> None:
527
528
  dy = other.y - survivor.y
528
529
  dist = math.hypot(dx, dy)
529
530
  if dist == 0:
530
- angle = random.uniform(0, math.tau)
531
+ angle = RNG.uniform(0, math.tau)
531
532
  dx, dy = math.cos(angle), math.sin(angle)
532
533
  dist = 1
533
534
  if dist < survivor_overlap:
@@ -637,7 +638,6 @@ def log_waiting_car_count(game_data: GameData, *, force: bool = False) -> None:
637
638
  current = len(game_data.waiting_cars)
638
639
  if not force and current == game_data.last_logged_waiting_cars:
639
640
  return
640
- stage_id = getattr(game_data.stage, "id", "unknown")
641
641
  game_data.last_logged_waiting_cars = current
642
642
 
643
643
 
@@ -662,7 +662,7 @@ def add_survivor_message(game_data: GameData, text: str) -> None:
662
662
  def random_survivor_conversion_line() -> str:
663
663
  if not SURVIVOR_CONVERSION_LINE_KEYS:
664
664
  return ""
665
- key = random.choice(SURVIVOR_CONVERSION_LINE_KEYS)
665
+ key = RNG.choice(SURVIVOR_CONVERSION_LINE_KEYS)
666
666
  return _(key)
667
667
 
668
668
 
@@ -685,8 +685,8 @@ def drop_survivors_from_car(game_data: GameData, origin: tuple[int, int]) -> Non
685
685
  for survivor_idx in range(count):
686
686
  placed = False
687
687
  for attempt in range(6):
688
- angle = random.uniform(0, math.tau)
689
- dist = random.uniform(16, 40)
688
+ angle = RNG.uniform(0, math.tau)
689
+ dist = RNG.uniform(16, 40)
690
690
  pos = (
691
691
  origin[0] + math.cos(angle) * dist,
692
692
  origin[1] + math.sin(angle) * dist,
@@ -878,6 +878,7 @@ def initialize_game_state(config: dict[str, Any], stage: Stage) -> GameData:
878
878
  survivors_rescued=0,
879
879
  survivor_messages=[],
880
880
  survivor_capacity=SURVIVOR_MAX_SAFE_PASSENGERS,
881
+ seed=None,
881
882
  )
882
883
 
883
884
  # Create sprite groups
@@ -937,7 +938,7 @@ def setup_player_and_cars(
937
938
 
938
939
  def pick_center(cells: list[pygame.Rect]) -> tuple[int, int]:
939
940
  return (
940
- random.choice(cells).center
941
+ RNG.choice(cells).center
941
942
  if cells
942
943
  else (LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2)
943
944
  )
@@ -952,7 +953,7 @@ def setup_player_and_cars(
952
953
  """Favor distant cells for the first car, otherwise fall back to random picks."""
953
954
  if not car_candidates:
954
955
  return (player_pos[0] + 200, player_pos[1])
955
- random.shuffle(car_candidates)
956
+ RNG.shuffle(car_candidates)
956
957
  for candidate in car_candidates:
957
958
  if (
958
959
  math.hypot(
@@ -1275,7 +1276,7 @@ def check_interactions(
1275
1276
  state.game_over_at = state.game_over_at or pygame.time.get_ticks()
1276
1277
  else:
1277
1278
  if walkable_cells:
1278
- new_cell = random.choice(walkable_cells)
1279
+ new_cell = RNG.choice(walkable_cells)
1279
1280
  companion.teleport(new_cell.center)
1280
1281
  else:
1281
1282
  companion.teleport((LEVEL_WIDTH // 2, LEVEL_HEIGHT // 2))
@@ -1,6 +1,6 @@
1
1
  # Blueprint generator for randomized layouts.
2
2
 
3
- import random
3
+ from .rng import get_rng
4
4
 
5
5
  GRID_COLS = 48
6
6
  GRID_ROWS = 30
@@ -12,6 +12,8 @@ WALL_MIN_LEN = 3
12
12
  WALL_MAX_LEN = 10
13
13
  SPAWN_MARGIN = 3 # keep spawns away from walls/edges
14
14
  SPAWN_ZOMBIES = 3
15
+
16
+ RNG = get_rng()
15
17
  STEEL_BEAM_CHANCE = 0.05
16
18
 
17
19
  # Legend:
@@ -61,7 +63,7 @@ def _init_grid(cols: int, rows: int) -> list[list[str]]:
61
63
 
62
64
  def _place_exits(grid: list[list[str]], exits_per_side: int) -> None:
63
65
  cols, rows = len(grid[0]), len(grid)
64
- rng = random.randint
66
+ rng = RNG.randint
65
67
  used = set()
66
68
 
67
69
  def pick_pos(side: str) -> tuple[int, int]:
@@ -87,13 +89,13 @@ def _place_exits(grid: list[list[str]], exits_per_side: int) -> None:
87
89
 
88
90
  def _place_internal_walls(grid: list[list[str]]) -> None:
89
91
  cols, rows = len(grid[0]), len(grid)
90
- rng = random.randint
92
+ rng = RNG.randint
91
93
  # Avoid placing walls adjacent to exits: collect forbidden cells (exits + neighbors)
92
94
  forbidden = _collect_exit_adjacent_cells(grid)
93
95
 
94
96
  for _ in range(NUM_WALL_LINES):
95
97
  length = rng(WALL_MIN_LEN, WALL_MAX_LEN)
96
- horizontal = random.choice([True, False])
98
+ horizontal = RNG.choice([True, False])
97
99
  if horizontal:
98
100
  y = rng(2, rows - 3)
99
101
  x = rng(2, cols - 2 - length)
@@ -125,7 +127,7 @@ def _place_steel_beams(
125
127
  continue
126
128
  if grid[y][x] not in (".", "1"):
127
129
  continue
128
- if random.random() < chance:
130
+ if RNG.random() < chance:
129
131
  beams.add((x, y))
130
132
  return beams
131
133
 
@@ -139,8 +141,8 @@ def _pick_empty_cell(
139
141
  attempts = 0
140
142
  while attempts < 2000:
141
143
  attempts += 1
142
- x = random.randint(margin, cols - margin - 1)
143
- y = random.randint(margin, rows - margin - 1)
144
+ x = RNG.randint(margin, cols - margin - 1)
145
+ y = RNG.randint(margin, rows - margin - 1)
144
146
  if grid[y][x] == "." and (x, y) not in forbidden:
145
147
  return x, y
146
148
  # Fallback: scan for any acceptable cell
@@ -16,7 +16,10 @@
16
16
  "settings": "Settings",
17
17
  "quit": "Quit",
18
18
  "locked_suffix": "[Locked]",
19
- "window_hint": "Resize window: [ to shrink, ] to enlarge (menu only)"
19
+ "window_hint": "Resize window: [ to shrink, ] to enlarge (menu only)",
20
+ "seed_label": "Seed: %{value}",
21
+ "seed_hint": "Type 0-9 to set a custom seed, Backspace clears",
22
+ "seed_empty": "(auto)"
20
23
  },
21
24
  "stages": {
22
25
  "stage1": {
@@ -47,7 +50,8 @@
47
50
  "fast": "FastZ",
48
51
  "car_hint": "CarHint",
49
52
  "flashlight": "Flashlight",
50
- "steel": "Steel"
53
+ "steel": "Steel",
54
+ "seed": "Seed %{value}"
51
55
  },
52
56
  "hud": {
53
57
  "need_fuel": "Need fuel to drive!",
@@ -16,7 +16,10 @@
16
16
  "settings": "設定",
17
17
  "quit": "終了",
18
18
  "locked_suffix": "[Locked]",
19
- "window_hint": "[キーでウィンドウを小さく、]キーで大きく(メニュー画面のみ)"
19
+ "window_hint": "[キーでウィンドウを小さく、]キーで大きく(メニュー画面のみ)",
20
+ "seed_label": "シード: %{value}",
21
+ "seed_hint": "数字キーで入力、BSでクリア",
22
+ "seed_empty": "(自動)"
20
23
  },
21
24
  "stages": {
22
25
  "stage1": {
@@ -47,7 +50,8 @@
47
50
  "fast": "高速ゾ",
48
51
  "car_hint": "車ヒント",
49
52
  "flashlight": "懐中電灯",
50
- "steel": "鉄筋"
53
+ "steel": "鉄筋",
54
+ "seed": "シード %{value}"
51
55
  },
52
56
  "hud": {
53
57
  "need_fuel": "燃料が必要です!",
@@ -49,6 +49,7 @@ class ProgressState:
49
49
  survivors_rescued: int
50
50
  survivor_messages: list
51
51
  survivor_capacity: int
52
+ seed: int | None
52
53
 
53
54
 
54
55
  @dataclass
@@ -280,6 +280,7 @@ def _draw_status_bar(
280
280
  config: dict[str, Any],
281
281
  *,
282
282
  stage: Stage | None = None,
283
+ seed: int | None = None,
283
284
  ) -> None:
284
285
  """Render a compact status bar with current config flags and stage info."""
285
286
  bar_rect = pygame.Rect(
@@ -327,6 +328,13 @@ def _draw_status_bar(
327
328
  text_surface = font.render(status_text, False, color)
328
329
  text_rect = text_surface.get_rect(left=12, centery=bar_rect.centery)
329
330
  screen.blit(text_surface, text_rect)
331
+ if seed is not None:
332
+ seed_text = _("status.seed", value=str(seed))
333
+ seed_surface = font.render(seed_text, False, LIGHT_GRAY)
334
+ seed_rect = seed_surface.get_rect(
335
+ right=bar_rect.right - 12, centery=bar_rect.centery
336
+ )
337
+ screen.blit(seed_surface, seed_rect)
330
338
  except pygame.error as e:
331
339
  print(f"Error rendering status bar: {e}")
332
340
 
@@ -573,7 +581,7 @@ def draw(
573
581
  screen.blit(msg_surface, msg_rect)
574
582
  except pygame.error as e:
575
583
  print(f"Error rendering survivor message: {e}")
576
- _draw_status_bar(screen, assets, config, stage=stage)
584
+ _draw_status_bar(screen, assets, config, stage=stage, seed=state.seed)
577
585
  if do_flip:
578
586
  if present_fn:
579
587
  present_fn(screen)
@@ -0,0 +1,132 @@
1
+ """Deterministic random number helpers for reproducible runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from typing import MutableSequence, Sequence, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ def generate_seed() -> int:
12
+ """Return a positive 63-bit seed for ad-hoc runs."""
13
+ return secrets.randbits(63) or 1
14
+
15
+
16
+ class DeterministicRNG:
17
+ """Mersenne Twister MT19937 implementation for deterministic runs."""
18
+
19
+ _N = 624
20
+ _M = 397
21
+ _MATRIX_A = 0x9908B0DF
22
+ _UPPER_MASK = 0x80000000
23
+ _LOWER_MASK = 0x7FFFFFFF
24
+
25
+ def __init__(self, seed: int | None = None) -> None:
26
+ self._state = [0] * self._N
27
+ self._index = self._N
28
+ self._seed_value: int | None = None
29
+ self.seed(seed)
30
+
31
+ def seed(self, value: int | None) -> None:
32
+ """Seed using the MT19937 initialization routine."""
33
+ if value is None:
34
+ value = generate_seed()
35
+ try:
36
+ normalized = int(value)
37
+ except (TypeError, ValueError) as exc:
38
+ raise ValueError(f"Invalid seed value: {value}") from exc
39
+ self._seed_value = normalized
40
+ seed32 = normalized & 0xFFFFFFFF
41
+ if seed32 == 0:
42
+ seed32 = 5489 # default MT seed
43
+ self._state[0] = seed32
44
+ for i in range(1, self._N):
45
+ prev = self._state[i - 1]
46
+ self._state[i] = (
47
+ (1812433253 * (prev ^ (prev >> 30)) + i) & 0xFFFFFFFF
48
+ )
49
+ self._index = self._N
50
+
51
+ @property
52
+ def seed_value(self) -> int | None:
53
+ return self._seed_value
54
+
55
+ def random(self) -> float:
56
+ """Return a float in the range [0.0, 1.0)."""
57
+ return self._next() / 4294967296.0 # 2**32
58
+
59
+ def randint(self, a: int, b: int) -> int:
60
+ if a > b:
61
+ raise ValueError("Lower bound must be <= upper bound for randint")
62
+ return a + self._randbelow(b - a + 1)
63
+
64
+ def choice(self, seq: Sequence[T]) -> T:
65
+ if not seq:
66
+ raise IndexError("Cannot choose from an empty sequence")
67
+ idx = self._randbelow(len(seq))
68
+ return seq[idx]
69
+
70
+ def shuffle(self, seq: MutableSequence[T]) -> None:
71
+ for i in range(len(seq) - 1, 0, -1):
72
+ j = self._randbelow(i + 1)
73
+ seq[i], seq[j] = seq[j], seq[i]
74
+
75
+ def uniform(self, a: float, b: float) -> float:
76
+ return a + (b - a) * self.random()
77
+
78
+ def _next(self) -> int:
79
+ return self._extract_number()
80
+
81
+ def _randbelow(self, bound: int) -> int:
82
+ if bound <= 0:
83
+ raise ValueError("Upper bound must be positive")
84
+ # Rejection sampling to avoid bias
85
+ limit = (1 << 32) - ((1 << 32) % bound)
86
+ while True:
87
+ value = self._next()
88
+ if value < limit:
89
+ return value % bound
90
+
91
+ def _extract_number(self) -> int:
92
+ if self._index >= self._N:
93
+ self._twist()
94
+ y = self._state[self._index]
95
+ self._index += 1
96
+ y ^= (y >> 11)
97
+ y ^= (y << 7) & 0x9D2C5680
98
+ y ^= (y << 15) & 0xEFC60000
99
+ y ^= (y >> 18)
100
+ return y & 0xFFFFFFFF
101
+
102
+ def _twist(self) -> None:
103
+ for i in range(self._N):
104
+ x = (self._state[i] & self._UPPER_MASK) + (
105
+ self._state[(i + 1) % self._N] & self._LOWER_MASK
106
+ )
107
+ xA = x >> 1
108
+ if x & 1:
109
+ xA ^= self._MATRIX_A
110
+ self._state[i] = self._state[(i + self._M) % self._N] ^ xA
111
+ self._index = 0
112
+
113
+
114
+ _GLOBAL_RNG = DeterministicRNG()
115
+
116
+
117
+ def get_rng() -> DeterministicRNG:
118
+ return _GLOBAL_RNG
119
+
120
+
121
+ def seed_rng(seed: int | None) -> int:
122
+ _GLOBAL_RNG.seed(seed)
123
+ assert _GLOBAL_RNG.seed_value is not None
124
+ return _GLOBAL_RNG.seed_value
125
+
126
+
127
+ __all__ = [
128
+ "DeterministicRNG",
129
+ "generate_seed",
130
+ "get_rng",
131
+ "seed_rng",
132
+ ]
@@ -48,6 +48,9 @@ class ScreenTransition:
48
48
  stage: Stage | None = None
49
49
  game_data: GameData | None = None
50
50
  config: dict | None = None
51
+ seed: int | None = None
52
+ seed_text: str | None = None
53
+ seed_is_auto: bool = False
51
54
 
52
55
 
53
56
  current_window_scale = DEFAULT_WINDOW_SCALE # Applied to the OS window only
@@ -6,7 +6,8 @@ import pygame
6
6
  from pygame import surface, time
7
7
 
8
8
  from ..colors import BLACK, GREEN, LIGHT_GRAY, RED, WHITE
9
- from ..localization import translate as _
9
+ from ..font_utils import load_font
10
+ from ..localization import get_font_settings, translate as _
10
11
  from ..models import GameData, Stage
11
12
  from ..render import RenderAssets, draw_level_overview, show_message
12
13
  from ..screens import ScreenID, ScreenTransition, present
@@ -119,6 +120,19 @@ def game_over_screen(
119
120
  (screen_width // 2, screen_height // 2 + 24),
120
121
  )
121
122
 
123
+ if state.seed is not None:
124
+ try:
125
+ font_settings = get_font_settings()
126
+ font = load_font(font_settings.resource, font_settings.scaled_size(11))
127
+ seed_text = _("status.seed", value=str(state.seed))
128
+ seed_surface = font.render(seed_text, False, LIGHT_GRAY)
129
+ seed_rect = seed_surface.get_rect(
130
+ right=screen_width - 14, bottom=screen_height - 12
131
+ )
132
+ screen.blit(seed_surface, seed_rect)
133
+ except pygame.error as exc:
134
+ print(f"Error rendering game-over seed text: {exc}")
135
+
122
136
  present(screen)
123
137
  clock.tick(fps)
124
138
 
@@ -15,6 +15,7 @@ from ..gameplay import logic
15
15
  from ..localization import translate as _
16
16
  from ..models import Stage
17
17
  from ..render import draw, show_message
18
+ from ..rng import generate_seed, seed_rng
18
19
  from ..screens import ScreenID, ScreenTransition, present
19
20
 
20
21
  if TYPE_CHECKING:
@@ -29,6 +30,7 @@ def gameplay_screen(
29
30
  stage: Stage,
30
31
  *,
31
32
  show_pause_overlay: bool,
33
+ seed: int | None,
32
34
  render_assets: "RenderAssets",
33
35
  ) -> ScreenTransition:
34
36
  """Main gameplay loop that returns the next screen transition."""
@@ -36,7 +38,11 @@ def gameplay_screen(
36
38
  screen_width = screen.get_width()
37
39
  screen_height = screen.get_height()
38
40
 
41
+ seed_value = seed if seed is not None else generate_seed()
42
+ applied_seed = seed_rng(seed_value)
43
+
39
44
  game_data = logic.initialize_game_state(config, stage)
45
+ game_data.state.seed = applied_seed
40
46
  paused_manual = False
41
47
  paused_focus = False
42
48
  last_fov_target = None
@@ -11,6 +11,15 @@ from ..localization import get_font_settings, translate as _
11
11
  from ..models import Stage
12
12
  from ..render import show_message
13
13
  from ..screens import ScreenID, ScreenTransition, nudge_window_scale, present
14
+ from ..rng import generate_seed
15
+
16
+ MAX_SEED_DIGITS = 19
17
+
18
+
19
+ def _generate_auto_seed_text() -> str:
20
+ raw = generate_seed()
21
+ trimmed = raw // 100 # drop lower 2 digits for stability
22
+ return str(trimmed % 100000).zfill(5)
14
23
 
15
24
 
16
25
  def title_screen(
@@ -22,6 +31,8 @@ def title_screen(
22
31
  stages: Sequence[Stage],
23
32
  default_stage_id: str,
24
33
  screen_size: tuple[int, int],
34
+ seed_text: str | None = None,
35
+ seed_is_auto: bool = False,
25
36
  ) -> ScreenTransition:
26
37
  """Display the title menu and return the selected transition."""
27
38
 
@@ -40,12 +51,30 @@ def title_screen(
40
51
  ),
41
52
  0,
42
53
  )
54
+ generated = seed_text is None
55
+ current_seed_text = seed_text if seed_text is not None else _generate_auto_seed_text()
56
+ current_seed_auto = seed_is_auto or generated
43
57
 
44
58
  while True:
45
59
  for event in pygame.event.get():
46
60
  if event.type == pygame.QUIT:
47
- return ScreenTransition(ScreenID.EXIT)
61
+ return ScreenTransition(
62
+ ScreenID.EXIT,
63
+ seed_text=current_seed_text,
64
+ seed_is_auto=current_seed_auto,
65
+ )
48
66
  if event.type == pygame.KEYDOWN:
67
+ if event.key == pygame.K_BACKSPACE:
68
+ current_seed_text = _generate_auto_seed_text()
69
+ current_seed_auto = True
70
+ continue
71
+ if event.unicode and event.unicode.isdigit():
72
+ if current_seed_auto:
73
+ current_seed_text = ""
74
+ current_seed_auto = False
75
+ if len(current_seed_text) < MAX_SEED_DIGITS:
76
+ current_seed_text += event.unicode
77
+ continue
49
78
  if event.key == pygame.K_LEFTBRACKET:
50
79
  nudge_window_scale(0.5)
51
80
  continue
@@ -59,13 +88,26 @@ def title_screen(
59
88
  elif event.key in (pygame.K_RETURN, pygame.K_SPACE):
60
89
  current = options[selected]
61
90
  if current["type"] == "stage" and current.get("available"):
91
+ seed_value = int(current_seed_text) if current_seed_text else None
62
92
  return ScreenTransition(
63
- ScreenID.GAMEPLAY, stage=current["stage"]
93
+ ScreenID.GAMEPLAY,
94
+ stage=current["stage"],
95
+ seed=seed_value,
96
+ seed_text=current_seed_text,
97
+ seed_is_auto=current_seed_auto,
64
98
  )
65
99
  if current["type"] == "settings":
66
- return ScreenTransition(ScreenID.SETTINGS)
100
+ return ScreenTransition(
101
+ ScreenID.SETTINGS,
102
+ seed_text=current_seed_text,
103
+ seed_is_auto=current_seed_auto,
104
+ )
67
105
  if current["type"] == "quit":
68
- return ScreenTransition(ScreenID.EXIT)
106
+ return ScreenTransition(
107
+ ScreenID.EXIT,
108
+ seed_text=current_seed_text,
109
+ seed_is_auto=current_seed_auto,
110
+ )
69
111
 
70
112
  screen.fill(BLACK)
71
113
  show_message(
@@ -117,11 +159,25 @@ def title_screen(
117
159
  desc_rect = desc_surface.get_rect(center=(width // 2, height // 2 + 74))
118
160
  screen.blit(desc_surface, desc_rect)
119
161
 
162
+ seed_font = load_font(font_settings.resource, font_settings.scaled_size(12))
163
+ seed_value_display = (
164
+ current_seed_text if current_seed_text else _("menu.seed_empty")
165
+ )
166
+ seed_label = _("status.seed", value=seed_value_display)
167
+ seed_surface = seed_font.render(seed_label, False, LIGHT_GRAY)
168
+ seed_rect = seed_surface.get_rect(right=width - 14, bottom=height - 12)
169
+ screen.blit(seed_surface, seed_rect)
170
+
120
171
  hint_font = load_font(font_settings.resource, font_settings.scaled_size(11))
121
172
  hint_text = _("menu.window_hint")
122
173
  hint_surface = hint_font.render(hint_text, False, LIGHT_GRAY)
123
- hint_rect = hint_surface.get_rect(center=(width // 2, height - 50))
174
+ hint_rect = hint_surface.get_rect(center=(width // 2, height - 60))
124
175
  screen.blit(hint_surface, hint_rect)
176
+
177
+ seed_hint = _("menu.seed_hint")
178
+ seed_hint_surface = hint_font.render(seed_hint, False, GRAY)
179
+ seed_hint_rect = seed_hint_surface.get_rect(left=14, bottom=height - 12)
180
+ screen.blit(seed_hint_surface, seed_hint_rect)
125
181
  except pygame.error as e:
126
182
  print(f"Error rendering title screen: {e}")
127
183
 
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import argparse
3
4
  import sys
4
5
  import traceback # For error reporting
5
- from typing import Any
6
+ from typing import Any, Tuple
6
7
 
7
8
  import pygame
8
9
 
@@ -26,9 +27,26 @@ from .models import GameData, Stage, STAGES, DEFAULT_STAGE_ID
26
27
  from .screens import ScreenID, ScreenTransition, apply_window_scale
27
28
  from .screens.game_over import game_over_screen
28
29
  from .screens.settings import settings_screen
29
- from .screens.title import title_screen
30
+ from .screens.title import MAX_SEED_DIGITS, title_screen
30
31
  from .gameplay.logic import calculate_car_speed_for_passengers
31
32
 
33
+
34
+ def _parse_cli_args(argv: list[str]) -> Tuple[argparse.Namespace, list[str]]:
35
+ parser = argparse.ArgumentParser(add_help=False)
36
+ parser.add_argument("--hide-pause-overlay", action="store_true")
37
+ parser.add_argument("--seed")
38
+ return parser.parse_known_args(argv)
39
+
40
+
41
+ def _sanitize_seed_text(raw: str | None) -> tuple[str | None, bool]:
42
+ if not raw:
43
+ return None, True
44
+ stripped = raw.strip()
45
+ if not stripped.isdigit():
46
+ print("Ignoring --seed value because it must contain only digits.")
47
+ return None, True
48
+ return stripped[:MAX_SEED_DIGITS], False
49
+
32
50
  # Re-export the gameplay helpers constants for external callers/tests.
33
51
  __all__ = [
34
52
  "main",
@@ -41,6 +59,9 @@ __all__ = [
41
59
 
42
60
  # --- Main Entry Point ---
43
61
  def main() -> None:
62
+ args, remaining = _parse_cli_args(sys.argv[1:])
63
+ sys.argv = [sys.argv[0]] + remaining
64
+
44
65
  pygame.init()
45
66
  try:
46
67
  pygame.font.init()
@@ -54,7 +75,9 @@ def main() -> None:
54
75
  screen = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)).convert_alpha()
55
76
  clock = pygame.time.Clock()
56
77
 
57
- hide_pause_overlay = "--hide-pause-overlay" in sys.argv
78
+ hide_pause_overlay = bool(args.hide_pause_overlay)
79
+ cli_seed_text, cli_seed_is_auto = _sanitize_seed_text(args.seed)
80
+ title_seed_text, title_seed_is_auto = cli_seed_text, cli_seed_is_auto
58
81
 
59
82
  config: dict[str, Any]
60
83
  config, config_path = load_config()
@@ -66,12 +89,14 @@ def main() -> None:
66
89
  pending_stage: Stage | None = None
67
90
  pending_game_data: GameData | None = None
68
91
  pending_config: dict[str, Any] | None = None
92
+ pending_seed: int | None = None
69
93
  running = True
70
94
 
71
95
  while running:
72
96
  transition: ScreenTransition | None = None
73
97
 
74
98
  if next_screen == ScreenID.TITLE:
99
+ seed_input = None if title_seed_is_auto else title_seed_text
75
100
  transition = title_screen(
76
101
  screen,
77
102
  clock,
@@ -80,7 +105,12 @@ def main() -> None:
80
105
  stages=STAGES,
81
106
  default_stage_id=DEFAULT_STAGE_ID,
82
107
  screen_size=(SCREEN_WIDTH, SCREEN_HEIGHT),
108
+ seed_text=seed_input,
109
+ seed_is_auto=title_seed_is_auto,
83
110
  )
111
+ if transition.seed_text is not None:
112
+ title_seed_text = transition.seed_text
113
+ title_seed_is_auto = transition.seed_is_auto
84
114
  elif next_screen == ScreenID.SETTINGS:
85
115
  config = settings_screen(
86
116
  screen,
@@ -95,6 +125,8 @@ def main() -> None:
95
125
  elif next_screen == ScreenID.GAMEPLAY:
96
126
  stage = pending_stage
97
127
  pending_stage = None
128
+ seed_value = pending_seed
129
+ pending_seed = None
98
130
  if stage is None:
99
131
  transition = ScreenTransition(ScreenID.TITLE)
100
132
  else:
@@ -106,6 +138,7 @@ def main() -> None:
106
138
  FPS,
107
139
  stage,
108
140
  show_pause_overlay=not hide_pause_overlay,
141
+ seed=seed_value,
109
142
  render_assets=RENDER_ASSETS,
110
143
  )
111
144
  except SystemExit:
@@ -143,6 +176,10 @@ def main() -> None:
143
176
  pending_stage = transition.stage
144
177
  pending_game_data = transition.game_data
145
178
  pending_config = transition.config
179
+ pending_seed = transition.seed
180
+ if transition.next_screen == ScreenID.GAMEPLAY:
181
+ title_seed_text = cli_seed_text
182
+ title_seed_is_auto = cli_seed_is_auto
146
183
  next_screen = transition.next_screen
147
184
 
148
185
  pygame.quit() # Quit pygame only once at the very end of main
File without changes
File without changes