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
@@ -41,5 +41,52 @@ def load_font(resource: str | None, size: int) -> pygame.font.Font:
41
41
  return font
42
42
 
43
43
 
44
+ def render_text_scaled(
45
+ resource: str | None,
46
+ size: int,
47
+ text: str,
48
+ color: tuple[int, int, int],
49
+ *,
50
+ scale_factor: int = 1,
51
+ antialias: bool = False,
52
+ ) -> pygame.Surface:
53
+ """Render text, optionally supersampling then downscaling."""
54
+ normalized_size = max(1, int(size))
55
+ if scale_factor <= 1:
56
+ font = load_font(resource, normalized_size)
57
+ return font.render(text, antialias, color)
58
+ high_size = max(1, int(round(normalized_size * scale_factor)))
59
+ font_high = load_font(resource, high_size)
60
+ high_surface = font_high.render(text, antialias, color)
61
+ target_width = max(1, int(round(high_surface.get_width() / scale_factor)))
62
+ target_height = max(1, int(round(high_surface.get_height() / scale_factor)))
63
+ return pygame.transform.scale(high_surface, (target_width, target_height))
64
+
65
+
66
+ def blit_text_scaled(
67
+ target: pygame.Surface,
68
+ resource: str | None,
69
+ size: int,
70
+ text: str,
71
+ color: tuple[int, int, int],
72
+ *,
73
+ scale_factor: int = 1,
74
+ antialias: bool = False,
75
+ **rect_kwargs: int | tuple[int, int],
76
+ ) -> pygame.Rect:
77
+ """Render scaled text and blit it to target using rect kwargs."""
78
+ surface = render_text_scaled(
79
+ resource,
80
+ size,
81
+ text,
82
+ color,
83
+ scale_factor=scale_factor,
84
+ antialias=antialias,
85
+ )
86
+ rect = surface.get_rect(**rect_kwargs)
87
+ target.blit(surface, rect)
88
+ return rect
89
+
90
+
44
91
  def clear_font_cache() -> None:
45
92
  _FONT_CACHE.clear()
@@ -22,7 +22,7 @@ from .spawn import (
22
22
  spawn_waiting_car,
23
23
  spawn_weighted_zombie,
24
24
  )
25
- from .state import carbonize_outdoor_zombies, initialize_game_state, update_endurance_timer
25
+ from .state import carbonize_outdoor_zombies, initialize_game_state, schedule_timed_message, update_endurance_timer
26
26
  from .survivors import (
27
27
  add_survivor_message,
28
28
  apply_passenger_speed_penalty,
@@ -72,6 +72,7 @@ __all__ = [
72
72
  "get_shrunk_sprite",
73
73
  "update_footprints",
74
74
  "initialize_game_state",
75
+ "schedule_timed_message",
75
76
  "setup_player_and_cars",
76
77
  "spawn_initial_zombies",
77
78
  "update_endurance_timer",
@@ -8,6 +8,8 @@ from ..entities_constants import ZOMBIE_AGING_DURATION_FRAMES
8
8
  SURVIVOR_SPEED_PENALTY_PER_PASSENGER = 0.08
9
9
  SURVIVOR_OVERLOAD_DAMAGE_RATIO = 0.2
10
10
  SURVIVOR_MESSAGE_DURATION_MS = 2000
11
+ INTRO_MESSAGE_DISPLAY_FRAMES = 180
12
+ SCREAM_MESSAGE_DISPLAY_FRAMES = 60
11
13
 
12
14
  # --- Footprint settings (gameplay) ---
13
15
  FOOTPRINT_STEP_DISTANCE = 40
@@ -32,6 +34,8 @@ __all__ = [
32
34
  "SURVIVOR_SPEED_PENALTY_PER_PASSENGER",
33
35
  "SURVIVOR_OVERLOAD_DAMAGE_RATIO",
34
36
  "SURVIVOR_MESSAGE_DURATION_MS",
37
+ "INTRO_MESSAGE_DISPLAY_FRAMES",
38
+ "SCREAM_MESSAGE_DISPLAY_FRAMES",
35
39
  "FOOTPRINT_STEP_DISTANCE",
36
40
  "FOOTPRINT_MAX",
37
41
  "MAX_ZOMBIES",
@@ -5,6 +5,9 @@ from typing import Any
5
5
  import pygame
6
6
 
7
7
  from ..entities_constants import (
8
+ BUDDY_FOLLOW_START_DISTANCE,
9
+ BUDDY_FOLLOW_STOP_DISTANCE,
10
+ BUDDY_MERGE_DISTANCE,
8
11
  CAR_HEIGHT,
9
12
  CAR_WIDTH,
10
13
  FLASHLIGHT_HEIGHT,
@@ -14,7 +17,6 @@ from ..entities_constants import (
14
17
  HUMANOID_RADIUS,
15
18
  SHOES_HEIGHT,
16
19
  SHOES_WIDTH,
17
- SURVIVOR_APPROACH_RADIUS,
18
20
  SURVIVOR_MAX_SAFE_PASSENGERS,
19
21
  )
20
22
  from .constants import (
@@ -22,9 +24,12 @@ from .constants import (
22
24
  FUEL_HINT_DURATION_MS,
23
25
  SURVIVOR_OVERLOAD_DAMAGE_RATIO,
24
26
  )
27
+ from ..colors import BLUE, YELLOW
25
28
  from ..localization import translate as tr
26
29
  from ..models import GameData
27
30
  from ..rng import get_rng
31
+ from ..render_constants import BUDDY_COLOR
32
+ from ..screen_constants import FPS
28
33
  from ..entities import Car
29
34
  from .footprints import get_shrunk_sprite
30
35
  from .spawn import maintain_waiting_car_supply
@@ -36,8 +41,10 @@ from .survivors import (
36
41
  increase_survivor_capacity,
37
42
  respawn_buddies_near_player,
38
43
  )
39
- from .utils import rect_visible_on_screen
44
+ from .utils import is_entity_in_fov, rect_visible_on_screen
40
45
  from .ambient import sync_ambient_palette_with_flashlights
46
+ from .constants import SCREAM_MESSAGE_DISPLAY_FRAMES
47
+ from .state import schedule_timed_message
41
48
 
42
49
 
43
50
  def _interaction_radius(width: float, height: float) -> float:
@@ -45,6 +52,12 @@ def _interaction_radius(width: float, height: float) -> float:
45
52
  return HUMANOID_RADIUS + (width + height) / 4
46
53
 
47
54
 
55
+ def _ms_to_frames(ms: int) -> int:
56
+ if ms <= 0:
57
+ return 0
58
+ return max(1, int(round(ms / (1000 / max(1, FPS)))))
59
+
60
+
48
61
  RNG = get_rng()
49
62
 
50
63
 
@@ -65,6 +78,7 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
65
78
  camera = game_data.camera
66
79
  stage = game_data.stage
67
80
  cell_size = game_data.cell_size
81
+ need_fuel_text = tr("hud.need_fuel")
68
82
  maintain_waiting_car_supply(game_data)
69
83
  active_car = car if car and car.alive() else None
70
84
  waiting_cars = game_data.waiting_cars
@@ -101,7 +115,8 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
101
115
  if fuel and fuel.alive() and not state.has_fuel and not player.in_car:
102
116
  if _player_near_point(fuel.rect.center, fuel_interaction_radius):
103
117
  state.has_fuel = True
104
- state.fuel_message_until = 0
118
+ if state.timed_message == need_fuel_text:
119
+ schedule_timed_message(state, None, duration_frames=0)
105
120
  state.hint_expires_at = 0
106
121
  state.hint_target_type = None
107
122
  fuel.kill()
@@ -145,7 +160,6 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
145
160
  buddies = [
146
161
  survivor for survivor in survivor_group if survivor.alive() and survivor.is_buddy and not survivor.rescued
147
162
  ]
148
-
149
163
  # Buddy interactions (Stage 3)
150
164
  if stage.buddy_required_count > 0 and buddies:
151
165
  for buddy in list(buddies):
@@ -154,7 +168,10 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
154
168
  buddy_on_screen = rect_visible_on_screen(camera, buddy.rect)
155
169
  if not player.in_car:
156
170
  dist_to_player_sq = (player.x - buddy.x) ** 2 + (player.y - buddy.y) ** 2
157
- if dist_to_player_sq <= SURVIVOR_APPROACH_RADIUS * SURVIVOR_APPROACH_RADIUS:
171
+ if buddy.following:
172
+ if dist_to_player_sq >= BUDDY_FOLLOW_STOP_DISTANCE * BUDDY_FOLLOW_STOP_DISTANCE:
173
+ buddy.following = False
174
+ elif dist_to_player_sq <= BUDDY_FOLLOW_START_DISTANCE * BUDDY_FOLLOW_START_DISTANCE:
158
175
  buddy.set_following()
159
176
  elif player.in_car and active_car and shrunk_car:
160
177
  g = pygame.sprite.Group()
@@ -174,8 +191,24 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
174
191
  continue
175
192
 
176
193
  if buddy.alive() and pygame.sprite.spritecollide(buddy, zombie_group, False, pygame.sprite.collide_circle):
177
- if buddy_on_screen:
178
- state.game_over_message = tr("game_over.scream")
194
+ fov_target = None
195
+ if player.in_car and active_car:
196
+ fov_target = active_car
197
+ else:
198
+ fov_target = player
199
+ buddy_in_fov = is_entity_in_fov(
200
+ buddy.rect,
201
+ fov_target=fov_target,
202
+ flashlight_count=state.flashlight_count,
203
+ )
204
+ if buddy_on_screen and buddy_in_fov:
205
+ schedule_timed_message(
206
+ state,
207
+ tr("game_over.scream"),
208
+ duration_frames=SCREAM_MESSAGE_DISPLAY_FRAMES,
209
+ clear_on_input=False,
210
+ color=BUDDY_COLOR,
211
+ )
179
212
  state.game_over = True
180
213
  state.game_over_at = state.game_over_at or pygame.time.get_ticks()
181
214
  else:
@@ -183,9 +216,23 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
183
216
  new_cell = RNG.choice(walkable_cells)
184
217
  buddy.teleport(_cell_center(new_cell))
185
218
  else:
186
- buddy.teleport((game_data.level_width // 2, game_data.level_height // 2))
219
+ buddy.teleport(game_data.layout.field_rect.center)
187
220
  buddy.following = False
188
221
 
222
+ if stage.buddy_required_count > 0:
223
+ near_following_count = 0
224
+ max_dist_sq = BUDDY_MERGE_DISTANCE * BUDDY_MERGE_DISTANCE
225
+ for buddy in buddies:
226
+ if not buddy.following:
227
+ continue
228
+ dx = player.x - buddy.x
229
+ dy = player.y - buddy.y
230
+ if (dx * dx + dy * dy) <= max_dist_sq:
231
+ near_following_count += 1
232
+ state.buddy_merged_count = state.buddy_onboard + near_following_count
233
+ else:
234
+ state.buddy_merged_count = 0
235
+
189
236
  # Player entering an active car already under control
190
237
  if not player.in_car and _player_near_car(active_car) and active_car and active_car.health > 0:
191
238
  if state.has_fuel:
@@ -196,8 +243,13 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
196
243
  print("Player entered car!")
197
244
  else:
198
245
  if not stage.endurance_stage:
199
- now_ms = state.elapsed_play_ms
200
- state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
246
+ schedule_timed_message(
247
+ state,
248
+ need_fuel_text,
249
+ duration_frames=_ms_to_frames(FUEL_HINT_DURATION_MS),
250
+ clear_on_input=False,
251
+ color=YELLOW,
252
+ )
201
253
  state.hint_target_type = "fuel"
202
254
 
203
255
  # Claim a waiting/parked car when the player finally reaches it
@@ -224,8 +276,13 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
224
276
  print("Player claimed a waiting car!")
225
277
  else:
226
278
  if not stage.endurance_stage:
227
- now_ms = state.elapsed_play_ms
228
- state.fuel_message_until = now_ms + FUEL_HINT_DURATION_MS
279
+ schedule_timed_message(
280
+ state,
281
+ need_fuel_text,
282
+ duration_frames=_ms_to_frames(FUEL_HINT_DURATION_MS),
283
+ clear_on_input=False,
284
+ color=YELLOW,
285
+ )
229
286
  state.hint_target_type = "fuel"
230
287
 
231
288
  # Bonus: collide a parked car while driving to repair/extend capabilities
@@ -307,7 +364,13 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
307
364
  if not state.game_over:
308
365
  state.game_over = True
309
366
  state.game_over_at = pygame.time.get_ticks()
310
- state.game_over_message = tr("game_over.scream")
367
+ schedule_timed_message(
368
+ state,
369
+ tr("game_over.scream"),
370
+ duration_frames=SCREAM_MESSAGE_DISPLAY_FRAMES,
371
+ clear_on_input=False,
372
+ color=BLUE,
373
+ )
311
374
 
312
375
  # Player escaping on foot after dawn (Stage 5)
313
376
  if (
@@ -318,17 +381,21 @@ def check_interactions(game_data: GameData, config: dict[str, Any]) -> None:
318
381
  and (player_cell := _rect_center_cell(player.rect)) is not None
319
382
  and player_cell in outside_cells
320
383
  ):
321
- state.game_won = True
384
+ buddy_ready = True
385
+ if stage.buddy_required_count > 0:
386
+ buddy_ready = state.buddy_merged_count >= stage.buddy_required_count
387
+ if buddy_ready:
388
+ state.game_won = True
322
389
 
323
390
  # Player escaping the level
324
391
  if player.in_car and car and car.alive() and state.has_fuel:
325
392
  buddy_ready = True
326
393
  if stage.buddy_required_count > 0:
327
- buddy_ready = state.buddy_onboard >= stage.buddy_required_count
394
+ buddy_ready = state.buddy_merged_count >= stage.buddy_required_count
328
395
  car_cell = _rect_center_cell(car.rect)
329
396
  if buddy_ready and car_cell is not None and car_cell in outside_cells:
330
397
  if stage.buddy_required_count > 0:
331
- state.buddy_rescued = min(stage.buddy_required_count, state.buddy_onboard)
398
+ state.buddy_rescued = min(stage.buddy_required_count, state.buddy_merged_count)
332
399
  if stage.rescue_stage and state.survivors_onboard:
333
400
  state.survivors_rescued += state.survivors_onboard
334
401
  state.survivors_onboard = 0
@@ -70,17 +70,12 @@ def generate_level_from_blueprint(game_data: GameData, config: dict[str, Any]) -
70
70
  pitfall_zones=stage.pitfall_zones,
71
71
  base_seed=game_data.state.seed,
72
72
  )
73
- if isinstance(blueprint_data, dict):
74
- blueprint = blueprint_data.get("grid", [])
75
- steel_cells_raw = blueprint_data.get("steel_cells", set())
76
- car_reachable_cells = blueprint_data.get("car_reachable_cells", set())
77
- else:
78
- blueprint = blueprint_data
79
- steel_cells_raw = set()
80
- car_reachable_cells = set()
73
+ game_data.blueprint = blueprint_data
74
+ blueprint = blueprint_data.grid
75
+ steel_cells_raw = blueprint_data.steel_cells
76
+ car_reachable_cells = blueprint_data.car_reachable_cells
81
77
 
82
78
  steel_cells = {(int(x), int(y)) for x, y in steel_cells_raw} if steel_enabled else set()
83
- game_data.layout.car_walkable_cells = car_reachable_cells
84
79
  cell_size = game_data.cell_size
85
80
  outer_wall_cells = {(x, y) for y, row in enumerate(blueprint) for x, ch in enumerate(row) if ch == "B"}
86
81
  wall_cells = {(x, y) for y, row in enumerate(blueprint) for x, ch in enumerate(row) if ch in {"B", "1"}}
@@ -95,7 +90,6 @@ def generate_level_from_blueprint(game_data: GameData, config: dict[str, Any]) -
95
90
  pitfall_cells: set[tuple[int, int]] = set()
96
91
  player_cells: list[tuple[int, int]] = []
97
92
  car_cells: list[tuple[int, int]] = []
98
- zombie_cells: list[tuple[int, int]] = []
99
93
  fuel_cells: list[tuple[int, int]] = []
100
94
  flashlight_cells: list[tuple[int, int]] = []
101
95
  shoes_cells: list[tuple[int, int]] = []
@@ -230,8 +224,6 @@ def generate_level_from_blueprint(game_data: GameData, config: dict[str, Any]) -
230
224
  player_cells.append((x, y))
231
225
  if ch == "C":
232
226
  car_cells.append((x, y))
233
- if ch == "Z":
234
- zombie_cells.append((x, y))
235
227
  if ch == "f":
236
228
  fuel_cells.append((x, y))
237
229
  if ch == "l":
@@ -252,14 +244,17 @@ def generate_level_from_blueprint(game_data: GameData, config: dict[str, Any]) -
252
244
  game_data.layout.field_rect = pygame.Rect(
253
245
  0,
254
246
  0,
255
- game_data.level_width,
256
- game_data.level_height,
247
+ stage.grid_cols * game_data.cell_size,
248
+ stage.grid_rows * game_data.cell_size,
257
249
  )
250
+ game_data.layout.grid_cols = stage.grid_cols
251
+ game_data.layout.grid_rows = stage.grid_rows
258
252
  game_data.layout.outside_cells = outside_cells
259
253
  game_data.layout.walkable_cells = walkable_cells
260
254
  game_data.layout.outer_wall_cells = outer_wall_cells
261
255
  game_data.layout.wall_cells = wall_cells
262
256
  game_data.layout.pitfall_cells = pitfall_cells
257
+ game_data.layout.car_walkable_cells = car_reachable_cells
263
258
  fall_spawn_cells = _expand_zone_cells(
264
259
  stage.fall_spawn_zones,
265
260
  grid_cols=stage.grid_cols,
@@ -284,7 +279,6 @@ def generate_level_from_blueprint(game_data: GameData, config: dict[str, Any]) -
284
279
  return {
285
280
  "player_cells": player_cells,
286
281
  "car_cells": car_cells,
287
- "zombie_cells": zombie_cells,
288
282
  "fuel_cells": fuel_cells,
289
283
  "flashlight_cells": flashlight_cells,
290
284
  "shoes_cells": shoes_cells,
@@ -16,6 +16,8 @@ from ..entities_constants import (
16
16
  HUMANOID_WALL_BUMP_FRAMES,
17
17
  PLAYER_SPEED,
18
18
  ZOMBIE_SEPARATION_DISTANCE,
19
+ ZOMBIE_TRACKER_CROWD_BAND_WIDTH,
20
+ ZOMBIE_TRACKER_GRID_CROWD_COUNT,
19
21
  ZOMBIE_WALL_HUG_SENSOR_DISTANCE,
20
22
  )
21
23
  from ..gameplay_constants import (
@@ -23,12 +25,15 @@ from ..gameplay_constants import (
23
25
  SHOES_SPEED_MULTIPLIER_TWO,
24
26
  )
25
27
  from ..models import FallingZombie, GameData
26
- from ..world_grid import WallIndex, apply_tile_edge_nudge, walls_for_radius
28
+ from ..rng import get_rng
29
+ from ..world_grid import WallIndex, apply_cell_edge_nudge, walls_for_radius
27
30
  from .constants import MAX_ZOMBIES
28
31
  from .spawn import spawn_weighted_zombie, update_falling_zombies
29
32
  from .survivors import update_survivors
30
33
  from .utils import rect_visible_on_screen
31
34
 
35
+ RNG = get_rng()
36
+
32
37
 
33
38
  def process_player_input(
34
39
  keys: Sequence[bool],
@@ -126,10 +131,8 @@ def update_entities(
126
131
  camera = game_data.camera
127
132
  stage = game_data.stage
128
133
  active_car = car if car and car.alive() else None
129
- wall_cells = game_data.layout.wall_cells
130
134
  pitfall_cells = game_data.layout.pitfall_cells
131
- walkable_cells = game_data.layout.walkable_cells
132
- bevel_corners = game_data.layout.bevel_corners
135
+ field_rect = game_data.layout.field_rect
133
136
 
134
137
  all_walls = list(wall_group) if wall_index is None else None
135
138
 
@@ -141,22 +144,19 @@ def update_entities(
141
144
  center,
142
145
  radius,
143
146
  cell_size=game_data.cell_size,
144
- grid_cols=stage.grid_cols,
145
- grid_rows=stage.grid_rows,
147
+ grid_cols=game_data.layout.grid_cols,
148
+ grid_rows=game_data.layout.grid_rows,
146
149
  )
147
150
 
148
151
  # Update player/car movement
149
152
  if player.in_car and active_car:
150
- car_dx, car_dy = apply_tile_edge_nudge(
153
+ car_dx, car_dy = apply_cell_edge_nudge(
151
154
  active_car.x,
152
155
  active_car.y,
153
156
  car_dx,
154
157
  car_dy,
158
+ layout=game_data.layout,
155
159
  cell_size=game_data.cell_size,
156
- wall_cells=wall_cells,
157
- bevel_corners=bevel_corners,
158
- grid_cols=stage.grid_cols,
159
- grid_rows=stage.grid_rows,
160
160
  )
161
161
  car_walls = _walls_near((active_car.x, active_car.y), 150.0)
162
162
  active_car.move(
@@ -167,22 +167,26 @@ def update_entities(
167
167
  cell_size=game_data.cell_size,
168
168
  pitfall_cells=pitfall_cells,
169
169
  )
170
+ if field_rect is not None:
171
+ car_allow_rect = field_rect.inflate(active_car.rect.width, active_car.rect.height)
172
+ clamped_rect = active_car.rect.clamp(car_allow_rect)
173
+ if clamped_rect.topleft != active_car.rect.topleft:
174
+ active_car.rect = clamped_rect
175
+ active_car.x = float(active_car.rect.centerx)
176
+ active_car.y = float(active_car.rect.centery)
170
177
  player.rect.center = active_car.rect.center
171
178
  player.x, player.y = active_car.x, active_car.y
172
179
  elif not player.in_car:
173
180
  # Ensure player is in all_sprites if not in car
174
181
  if player not in all_sprites:
175
182
  all_sprites.add(player, layer=2)
176
- player_dx, player_dy = apply_tile_edge_nudge(
183
+ player_dx, player_dy = apply_cell_edge_nudge(
177
184
  player.x,
178
185
  player.y,
179
186
  player_dx,
180
187
  player_dy,
188
+ layout=game_data.layout,
181
189
  cell_size=game_data.cell_size,
182
- wall_cells=wall_cells,
183
- bevel_corners=bevel_corners,
184
- grid_cols=stage.grid_cols,
185
- grid_rows=stage.grid_rows,
186
190
  )
187
191
  player.move(
188
192
  player_dx,
@@ -190,10 +194,7 @@ def update_entities(
190
194
  wall_group,
191
195
  wall_index=wall_index,
192
196
  cell_size=game_data.cell_size,
193
- level_width=game_data.level_width,
194
- level_height=game_data.level_height,
195
- pitfall_cells=pitfall_cells,
196
- walkable_cells=walkable_cells,
197
+ layout=game_data.layout,
197
198
  )
198
199
  else:
199
200
  # Player flagged as in-car but car is gone; drop them back to foot control
@@ -248,6 +249,28 @@ def update_entities(
248
249
 
249
250
  zombies_sorted: list[Zombie] = sorted(list(zombie_group), key=lambda z: z.x)
250
251
 
252
+ tracker_buckets: dict[tuple[int, int, int], list[Zombie]] = {}
253
+ tracker_cell_size = ZOMBIE_TRACKER_CROWD_BAND_WIDTH
254
+ angle_step = math.pi / 4.0
255
+ for zombie in zombies_sorted:
256
+ if not zombie.alive() or not zombie.tracker:
257
+ continue
258
+ zombie.tracker_force_wander = False
259
+ dx = zombie.last_move_dx
260
+ dy = zombie.last_move_dy
261
+ if abs(dx) <= 0.001 and abs(dy) <= 0.001:
262
+ continue
263
+ angle = math.atan2(dy, dx)
264
+ angle_bin = int(round(angle / angle_step)) % 8
265
+ cell_x = int(zombie.x // tracker_cell_size)
266
+ cell_y = int(zombie.y // tracker_cell_size)
267
+ tracker_buckets.setdefault((cell_x, cell_y, angle_bin), []).append(zombie)
268
+
269
+ for bucket in tracker_buckets.values():
270
+ if len(bucket) < ZOMBIE_TRACKER_GRID_CROWD_COUNT:
271
+ continue
272
+ RNG.choice(bucket).tracker_force_wander = True
273
+
251
274
  def _nearby_zombies(index: int) -> list[Zombie]:
252
275
  center = zombies_sorted[index]
253
276
  neighbors: list[Zombie] = []
@@ -303,14 +326,7 @@ def update_entities(
303
326
  nearby_candidates,
304
327
  footprints=game_data.state.footprints,
305
328
  cell_size=game_data.cell_size,
306
- grid_cols=stage.grid_cols,
307
- grid_rows=stage.grid_rows,
308
- level_width=game_data.level_width,
309
- level_height=game_data.level_height,
310
- outer_wall_cells=game_data.layout.outer_wall_cells,
311
- wall_cells=game_data.layout.wall_cells,
312
- pitfall_cells=game_data.layout.pitfall_cells,
313
- bevel_corners=game_data.layout.bevel_corners,
329
+ layout=game_data.layout,
314
330
  )
315
331
 
316
332
  # Check zombie pitfall
@@ -327,7 +343,7 @@ def update_entities(
327
343
  fall = FallingZombie(
328
344
  start_pos=(int(zombie.x), int(zombie.y)),
329
345
  target_pos=pitfall_target,
330
- started_at_ms=pygame.time.get_ticks(),
346
+ started_at_ms=game_data.state.elapsed_play_ms,
331
347
  pre_fx_ms=0,
332
348
  fall_duration_ms=500,
333
349
  dust_duration_ms=0,
@@ -17,7 +17,6 @@ from ..entities import (
17
17
  )
18
18
  from ..entities_constants import (
19
19
  FAST_ZOMBIE_BASE_SPEED,
20
- FOV_RADIUS,
21
20
  PLAYER_SPEED,
22
21
  ZOMBIE_AGING_DURATION_FRAMES,
23
22
  ZOMBIE_SPEED,
@@ -26,13 +25,8 @@ from ..gameplay_constants import (
26
25
  DEFAULT_FLASHLIGHT_SPAWN_COUNT,
27
26
  DEFAULT_SHOES_SPAWN_COUNT,
28
27
  )
29
- from ..level_constants import DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TILE_SIZE
28
+ from ..level_constants import DEFAULT_CELL_SIZE, DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS
30
29
  from ..models import DustRing, FallingZombie, GameData, Stage
31
- from ..render_constants import (
32
- FLASHLIGHT_FOG_SCALE_ONE,
33
- FLASHLIGHT_FOG_SCALE_TWO,
34
- FOG_RADIUS_SCALE,
35
- )
36
30
  from ..rng import get_rng
37
31
  from .constants import (
38
32
  FALLING_ZOMBIE_DUST_DURATION_MS,
@@ -43,6 +37,7 @@ from .constants import (
43
37
  ZOMBIE_TRACKER_AGING_DURATION_FRAMES,
44
38
  )
45
39
  from .utils import (
40
+ fov_radius_for_flashlights,
46
41
  find_exterior_spawn_position,
47
42
  find_interior_spawn_positions,
48
43
  find_nearby_offscreen_spawn_position,
@@ -111,17 +106,6 @@ def _pick_zombie_variant(stage: Stage | None) -> tuple[bool, bool]:
111
106
  return False, True
112
107
 
113
108
 
114
- def _fov_radius_for_flashlights(flashlight_count: int) -> float:
115
- count = max(0, int(flashlight_count))
116
- if count <= 0:
117
- scale = FOG_RADIUS_SCALE
118
- elif count == 1:
119
- scale = FLASHLIGHT_FOG_SCALE_ONE
120
- else:
121
- scale = FLASHLIGHT_FOG_SCALE_TWO
122
- return FOV_RADIUS * scale
123
-
124
-
125
109
  def _is_spawn_position_clear(
126
110
  game_data: GameData,
127
111
  candidate: Zombie,
@@ -168,7 +152,7 @@ def _pick_fall_spawn_position(
168
152
  target_sprite = car if player.in_car and car and car.alive() else player
169
153
  target_center = target_sprite.rect.center
170
154
  cell_size = game_data.cell_size
171
- fov_radius = _fov_radius_for_flashlights(game_data.state.flashlight_count)
155
+ fov_radius = fov_radius_for_flashlights(game_data.state.flashlight_count)
172
156
  min_dist_sq = min_distance * min_distance
173
157
  max_dist_sq = fov_radius * fov_radius
174
158
  wall_cells = game_data.layout.wall_cells
@@ -212,7 +196,7 @@ def _schedule_falling_zombie(
212
196
  zombie_group = game_data.groups.zombie_group
213
197
  if len(zombie_group) + len(state.falling_zombies) >= MAX_ZOMBIES:
214
198
  return "blocked"
215
- min_distance = game_data.stage.tile_size * 0.5
199
+ min_distance = game_data.stage.cell_size * 0.5
216
200
  tracker, wall_hugging = _pick_zombie_variant(game_data.stage)
217
201
 
218
202
  def _candidate_clear(pos: tuple[int, int]) -> bool:
@@ -239,7 +223,7 @@ def _schedule_falling_zombie(
239
223
  fall = FallingZombie(
240
224
  start_pos=start_pos,
241
225
  target_pos=(int(spawn_pos[0]), int(spawn_pos[1])),
242
- started_at_ms=pygame.time.get_ticks(),
226
+ started_at_ms=game_data.state.elapsed_play_ms,
243
227
  pre_fx_ms=FALLING_ZOMBIE_PRE_FX_MS,
244
228
  fall_duration_ms=FALLING_ZOMBIE_DURATION_MS,
245
229
  dust_duration_ms=FALLING_ZOMBIE_DUST_DURATION_MS,
@@ -290,15 +274,15 @@ def _create_zombie(
290
274
  )
291
275
  aging_duration_frames = max(1.0, aging_duration_frames * ratio)
292
276
  if start_pos is None:
293
- tile_size = stage.tile_size if stage else DEFAULT_TILE_SIZE
277
+ cell_size = stage.cell_size if stage else DEFAULT_CELL_SIZE
294
278
  if stage is None:
295
279
  grid_cols = DEFAULT_GRID_COLS
296
280
  grid_rows = DEFAULT_GRID_ROWS
297
281
  else:
298
282
  grid_cols = stage.grid_cols
299
283
  grid_rows = stage.grid_rows
300
- level_width = grid_cols * tile_size
301
- level_height = grid_rows * tile_size
284
+ level_width = grid_cols * cell_size
285
+ level_height = grid_rows * cell_size
302
286
  if hint_pos is not None:
303
287
  points = [random_position_outside_building(level_width, level_height) for _ in range(5)]
304
288
  points.sort(key=lambda p: (p[0] - hint_pos[0]) ** 2 + (p[1] - hint_pos[1]) ** 2)
@@ -625,12 +609,13 @@ def setup_player_and_cars(
625
609
  all_sprites = game_data.groups.all_sprites
626
610
  walkable_cells: list[tuple[int, int]] = layout_data["walkable_cells"]
627
611
  cell_size = game_data.cell_size
612
+ level_rect = game_data.layout.field_rect
628
613
 
629
614
  def _pick_center(cells: list[tuple[int, int]]) -> tuple[int, int]:
630
615
  return (
631
616
  _cell_center(RNG.choice(cells), cell_size)
632
617
  if cells
633
- else (game_data.level_width // 2, game_data.level_height // 2)
618
+ else level_rect.center
634
619
  )
635
620
 
636
621
  player_pos = _pick_center(layout_data["player_cells"] or walkable_cells)
@@ -677,10 +662,10 @@ def spawn_initial_zombies(
677
662
  zombie_group = game_data.groups.zombie_group
678
663
  all_sprites = game_data.groups.all_sprites
679
664
 
665
+ cell_size = game_data.cell_size
680
666
  spawn_cells = layout_data["walkable_cells"]
681
667
  if not spawn_cells:
682
668
  return
683
- cell_size = game_data.cell_size
684
669
 
685
670
  if game_data.stage.id == "debug_tracker":
686
671
  player_pos = player.rect.center
@@ -860,9 +845,10 @@ def spawn_exterior_zombie(
860
845
  return None
861
846
  zombie_group = game_data.groups.zombie_group
862
847
  all_sprites = game_data.groups.all_sprites
848
+ level_rect = game_data.layout.field_rect
863
849
  spawn_pos = find_exterior_spawn_position(
864
- game_data.level_width,
865
- game_data.level_height,
850
+ level_rect.width,
851
+ level_rect.height,
866
852
  hint_pos=(player.x, player.y),
867
853
  )
868
854
  new_zombie = _create_zombie(
@@ -879,7 +865,7 @@ def update_falling_zombies(game_data: GameData, config: dict[str, Any]) -> None:
879
865
  state = game_data.state
880
866
  if not state.falling_zombies:
881
867
  return
882
- now = pygame.time.get_ticks()
868
+ now = state.elapsed_play_ms
883
869
  zombie_group = game_data.groups.zombie_group
884
870
  all_sprites = game_data.groups.all_sprites
885
871
  for fall in list(state.falling_zombies):