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
@@ -7,7 +7,7 @@ import pygame
7
7
  from pygame import sprite, surface
8
8
 
9
9
  from ..colors import LIGHT_GRAY, ORANGE, YELLOW
10
- from ..entities import Camera, Car, Player
10
+ from ..entities import Car, Player
11
11
  from ..entities_constants import (
12
12
  FLASHLIGHT_HEIGHT,
13
13
  FLASHLIGHT_WIDTH,
@@ -20,18 +20,25 @@ from ..font_utils import load_font
20
20
  from ..gameplay_constants import SURVIVAL_FAKE_CLOCK_RATIO
21
21
  from ..localization import get_font_settings
22
22
  from ..localization import translate as tr
23
- from ..models import Stage
23
+ from ..models import Stage, TimedMessage
24
24
  from ..render_assets import (
25
25
  RenderAssets,
26
26
  build_flashlight_surface,
27
27
  build_fuel_can_surface,
28
28
  build_shoes_surface,
29
29
  )
30
- from ..render_constants import FLASHLIGHT_FOG_SCALE_ONE, FLASHLIGHT_FOG_SCALE_TWO
30
+ from ..render_constants import (
31
+ FLASHLIGHT_FOG_SCALE_ONE,
32
+ FLASHLIGHT_FOG_SCALE_TWO,
33
+ GAMEPLAY_FONT_SIZE,
34
+ HUD_ICON_SIZE,
35
+ TIMED_MESSAGE_BAND_ALPHA,
36
+ TIMED_MESSAGE_LEFT_X,
37
+ TIMED_MESSAGE_TOP_Y,
38
+ )
31
39
 
32
40
  _HUD_ICON_CACHE: dict[str, surface.Surface] = {}
33
41
 
34
- HUD_ICON_SIZE = 12
35
42
 
36
43
 
37
44
  def _scale_icon_to_box(icon: surface.Surface, size: int) -> surface.Surface:
@@ -133,7 +140,7 @@ def _draw_status_bar(
133
140
 
134
141
  try:
135
142
  font_settings = get_font_settings()
136
- font = load_font(font_settings.resource, font_settings.scaled_size(11))
143
+ font = load_font(font_settings.resource, font_settings.scaled_size(GAMEPLAY_FONT_SIZE))
137
144
  text_surface = font.render(status_text, False, color)
138
145
  text_rect = text_surface.get_rect(left=12, centery=bar_rect.centery)
139
146
  screen.blit(text_surface, text_rect)
@@ -145,10 +152,7 @@ def _draw_status_bar(
145
152
  if show_fps and fps is not None:
146
153
  fps_text = f"FPS:{fps:.1f}"
147
154
  fps_surface = font.render(fps_text, False, LIGHT_GRAY)
148
- fps_rect = fps_surface.get_rect(
149
- left=12,
150
- bottom=max(2, bar_rect.top - 4),
151
- )
155
+ fps_rect = fps_surface.get_rect(left=12, bottom=max(2, bar_rect.top))
152
156
  screen.blit(fps_surface, fps_rect)
153
157
  except pygame.error as e:
154
158
  print(f"Error rendering status bar: {e}")
@@ -157,7 +161,7 @@ def _draw_status_bar(
157
161
  def _draw_objective(lines: list[str], *, screen: surface.Surface) -> None:
158
162
  try:
159
163
  font_settings = get_font_settings()
160
- font = load_font(font_settings.resource, font_settings.scaled_size(11))
164
+ font = load_font(font_settings.resource, font_settings.scaled_size(GAMEPLAY_FONT_SIZE))
161
165
  y = 8
162
166
  for line in lines:
163
167
  text_surface = font.render(line, False, YELLOW)
@@ -212,8 +216,10 @@ def _draw_endurance_timer(
212
216
  elapsed_ms = max(0, min(goal_ms, state.endurance_elapsed_ms))
213
217
  remaining_ms = max(0, goal_ms - elapsed_ms)
214
218
  padding = 12
215
- bar_height = 8
216
- y_pos = assets.screen_height - assets.status_bar_height - bar_height - 10
219
+ bar_height = 6
220
+ text_bottom = assets.screen_height - assets.status_bar_height - bar_height - 8
221
+ bar_overlap = 6
222
+ y_pos = text_bottom + 2 - bar_overlap
217
223
  bar_rect = pygame.Rect(
218
224
  padding,
219
225
  y_pos,
@@ -226,16 +232,18 @@ def _draw_endurance_timer(
226
232
  progress_ratio = elapsed_ms / goal_ms if goal_ms else 0.0
227
233
  progress_width = int(bar_rect.width * max(0.0, min(1.0, progress_ratio)))
228
234
  if progress_width > 0:
229
- fill_color = (120, 20, 20)
235
+ fill_color = (120, 20, 20, 160)
230
236
  if state.dawn_ready:
231
- fill_color = (25, 40, 120)
237
+ fill_color = (25, 40, 120, 160)
232
238
  fill_rect = pygame.Rect(
233
239
  bar_rect.left,
234
240
  bar_rect.top,
235
241
  progress_width,
236
242
  bar_rect.height,
237
243
  )
238
- pygame.draw.rect(screen, fill_color, fill_rect)
244
+ fill_surface = pygame.Surface((progress_width, bar_rect.height), pygame.SRCALPHA)
245
+ fill_surface.fill(fill_color)
246
+ screen.blit(fill_surface, fill_rect.topleft)
239
247
  display_ms = int(remaining_ms * SURVIVAL_FAKE_CLOCK_RATIO)
240
248
  display_ms = max(0, display_ms)
241
249
  display_hours = display_ms // 3_600_000
@@ -244,19 +252,19 @@ def _draw_endurance_timer(
244
252
  timer_text = tr("hud.endurance_timer_label", time=display_label)
245
253
  try:
246
254
  font_settings = get_font_settings()
247
- font = load_font(font_settings.resource, font_settings.scaled_size(12))
255
+ font = load_font(font_settings.resource, font_settings.scaled_size(GAMEPLAY_FONT_SIZE))
248
256
  text_surface = font.render(timer_text, False, LIGHT_GRAY)
249
- text_rect = text_surface.get_rect(left=bar_rect.left, bottom=bar_rect.top - 2)
257
+ text_rect = text_surface.get_rect(left=bar_rect.left, bottom=text_bottom)
250
258
  screen.blit(text_surface, text_rect)
251
259
  if state.time_accel_active:
252
260
  accel_text = tr("hud.time_accel")
253
261
  accel_surface = font.render(accel_text, False, YELLOW)
254
- accel_rect = accel_surface.get_rect(right=bar_rect.right, bottom=bar_rect.top - 2)
262
+ accel_rect = accel_surface.get_rect(right=bar_rect.right, bottom=text_bottom)
255
263
  screen.blit(accel_surface, accel_rect)
256
264
  else:
257
265
  hint_text = tr("hud.time_accel_hint")
258
266
  hint_surface = font.render(hint_text, False, LIGHT_GRAY)
259
- hint_rect = hint_surface.get_rect(right=bar_rect.right, bottom=bar_rect.top - 2)
267
+ hint_rect = hint_surface.get_rect(right=bar_rect.right, bottom=text_bottom)
260
268
  screen.blit(hint_surface, hint_rect)
261
269
  except pygame.error as e:
262
270
  print(f"Error rendering endurance timer: {e}")
@@ -273,7 +281,7 @@ def _draw_time_accel_indicator(
273
281
  return
274
282
  try:
275
283
  font_settings = get_font_settings()
276
- font = load_font(font_settings.resource, font_settings.scaled_size(12))
284
+ font = load_font(font_settings.resource, font_settings.scaled_size(GAMEPLAY_FONT_SIZE))
277
285
  if state.time_accel_active:
278
286
  text = tr("hud.time_accel")
279
287
  color = YELLOW
@@ -300,19 +308,69 @@ def _draw_survivor_messages(
300
308
  return
301
309
  try:
302
310
  font_settings = get_font_settings()
303
- font = load_font(font_settings.resource, font_settings.scaled_size(14))
304
- base_y = assets.screen_height // 2 - 70
305
- for idx, message in enumerate(survivor_messages[:3]):
311
+ font_size = font_settings.scaled_size(GAMEPLAY_FONT_SIZE * 2)
312
+ font = load_font(font_settings.resource, font_size)
313
+ line_height = int(round(font.get_linesize() * 2))
314
+ base_y = assets.screen_height // 2 - (line_height * 2)
315
+ for idx, message in enumerate(survivor_messages[:5]):
306
316
  text = message.get("text", "")
307
317
  if not text:
308
318
  continue
309
319
  msg_surface = font.render(text, False, ORANGE)
310
- msg_rect = msg_surface.get_rect(center=(assets.screen_width // 2, base_y + idx * 18))
320
+ msg_rect = msg_surface.get_rect(center=(assets.screen_width // 2, base_y + idx * line_height))
311
321
  screen.blit(msg_surface, msg_rect)
312
322
  except pygame.error as e:
313
323
  print(f"Error rendering survivor message: {e}")
314
324
 
315
325
 
326
+ def _draw_timed_message(
327
+ screen: surface.Surface,
328
+ assets: RenderAssets,
329
+ *,
330
+ message: TimedMessage | None,
331
+ elapsed_play_ms: int,
332
+ ) -> None:
333
+ if not message:
334
+ return
335
+ if message.expires_at_ms <= 0:
336
+ return
337
+ if elapsed_play_ms > message.expires_at_ms:
338
+ return
339
+ try:
340
+ font_settings = get_font_settings()
341
+ font_size = font_settings.scaled_size(GAMEPLAY_FONT_SIZE * 2)
342
+ font = load_font(font_settings.resource, font_size)
343
+ text_color = message.color or LIGHT_GRAY
344
+ line_height = int(round(font.get_linesize() * font_settings.line_height_scale))
345
+ lines = message.text.splitlines() or [message.text]
346
+ rendered_lines = [font.render(line, False, text_color) for line in lines]
347
+ max_width = max(surface.get_width() for surface in rendered_lines)
348
+ total_height = line_height * len(rendered_lines)
349
+ if message.align == "left":
350
+ text_rect = pygame.Rect(TIMED_MESSAGE_LEFT_X, TIMED_MESSAGE_TOP_Y, max_width, total_height)
351
+ else:
352
+ center_x = assets.screen_width // 2
353
+ center_y = assets.screen_height // 2
354
+ text_rect = pygame.Rect(0, 0, max_width, total_height)
355
+ text_rect.center = (center_x, center_y)
356
+ padding_x = 16
357
+ padding_y = max(8, int(round(line_height * 0.35)))
358
+ band_rect = text_rect.inflate(padding_x * 2, padding_y * 2)
359
+ band_surface = pygame.Surface(band_rect.size, pygame.SRCALPHA)
360
+ band_surface.fill((0, 0, 0, TIMED_MESSAGE_BAND_ALPHA))
361
+ screen.blit(band_surface, band_rect.topleft)
362
+ y = text_rect.top
363
+ for surface in rendered_lines:
364
+ if message.align == "left":
365
+ line_rect = surface.get_rect(topleft=(text_rect.left, y))
366
+ else:
367
+ line_rect = surface.get_rect(centerx=text_rect.centerx, y=y)
368
+ screen.blit(surface, line_rect)
369
+ y += line_height
370
+ except pygame.error as e:
371
+ print(f"Error rendering timed message: {e}")
372
+
373
+
316
374
  def _build_objective_lines(
317
375
  *,
318
376
  stage: Stage | None,
@@ -320,7 +378,7 @@ def _build_objective_lines(
320
378
  player: Player,
321
379
  active_car: Car | None,
322
380
  has_fuel: bool,
323
- buddy_onboard: int,
381
+ buddy_merged_count: int,
324
382
  buddy_required: int,
325
383
  survivors_onboard: int,
326
384
  ) -> list[str]:
@@ -330,23 +388,37 @@ def _build_objective_lines(
330
388
  objective_lines.append(tr("objectives.get_outside"))
331
389
  else:
332
390
  objective_lines.append(tr("objectives.survive_until_dawn"))
333
- elif stage and stage.buddy_required_count > 0:
334
- buddy_ready = buddy_onboard >= buddy_required
335
- if not active_car:
336
- objective_lines.append(tr("objectives.pickup_buddy"))
337
- if stage.requires_fuel and not has_fuel:
338
- objective_lines.append(tr("objectives.find_fuel"))
391
+ if stage.buddy_required_count > 0:
392
+ buddy_ready = buddy_merged_count >= buddy_required
393
+ if not buddy_ready:
394
+ if buddy_required == 1:
395
+ objective_lines.append(tr("objectives.merge_buddy_single"))
396
+ else:
397
+ objective_lines.append(
398
+ tr("objectives.merge_buddy_multi", count=buddy_merged_count, limit=buddy_required)
399
+ )
400
+ return objective_lines
401
+
402
+ if stage and stage.buddy_required_count > 0:
403
+ buddy_ready = buddy_merged_count >= buddy_required
404
+ if not buddy_ready:
405
+ if buddy_required == 1:
406
+ objective_lines.append(tr("objectives.merge_buddy_single"))
339
407
  else:
340
- objective_lines.append(tr("objectives.find_car"))
341
- else:
342
- if stage.requires_fuel and not has_fuel:
343
- objective_lines.append(tr("objectives.find_fuel"))
344
- elif not buddy_ready:
345
- objective_lines.append(tr("objectives.board_buddy"))
346
- objective_lines.append(tr("objectives.buddy_onboard", count=buddy_onboard))
347
- objective_lines.append(tr("objectives.escape"))
408
+ objective_lines.append(
409
+ tr("objectives.merge_buddy_multi", count=buddy_merged_count, limit=buddy_required)
410
+ )
411
+ if not stage.endurance_stage:
412
+ if not active_car:
413
+ if stage.requires_fuel and not has_fuel:
414
+ objective_lines.append(tr("objectives.find_fuel"))
415
+ else:
416
+ objective_lines.append(tr("objectives.find_car"))
348
417
  else:
349
- objective_lines.append(tr("objectives.escape"))
418
+ if stage.requires_fuel and not has_fuel:
419
+ objective_lines.append(tr("objectives.find_fuel"))
420
+ else:
421
+ objective_lines.append(tr("objectives.escape"))
350
422
  elif stage and stage.requires_fuel and not has_fuel:
351
423
  objective_lines.append(tr("objectives.find_fuel"))
352
424
  elif stage and stage.rescue_stage:
@@ -3,10 +3,13 @@ from __future__ import annotations
3
3
  import pygame
4
4
  from pygame import sprite, surface
5
5
 
6
- from ..colors import BLACK, BLUE, FOOTPRINT_COLOR, WHITE, YELLOW, get_environment_palette
6
+ from ..colors import BLACK, BLUE, FOOTPRINT_COLOR, YELLOW, WHITE, get_environment_palette
7
7
  from ..entities import Car, Flashlight, FuelCan, Player, Shoes, SteelBeam, Survivor, Wall
8
+ from ..font_utils import load_font
9
+ from ..localization import get_font_settings
8
10
  from ..models import Footprint, GameData
9
11
  from ..render_assets import RenderAssets, resolve_steel_beam_colors, resolve_wall_colors
12
+ from .hud import _get_fog_scale
10
13
 
11
14
 
12
15
  def compute_floor_cells(
@@ -24,6 +27,31 @@ def compute_floor_cells(
24
27
  return {(x, y) for y in range(rows) for x in range(cols) if (x, y) not in blocked}
25
28
 
26
29
 
30
+ def _draw_overview_tag(
31
+ surface: surface.Surface,
32
+ font: pygame.font.Font,
33
+ text: str,
34
+ item_rect: pygame.Rect,
35
+ *,
36
+ fg: tuple[int, int, int] = YELLOW,
37
+ padding: tuple[int, int] = (4, 2),
38
+ ) -> None:
39
+ label = font.render(text, False, fg)
40
+ label_rect = label.get_rect()
41
+ padded = label_rect.inflate(padding[0] * 2, padding[1] * 2)
42
+ top_left = (item_rect.left, item_rect.top)
43
+ bottom_left = (item_rect.left, item_rect.bottom - padded.height)
44
+ if top_left[1] < 0 or top_left[1] + padded.height > surface.get_height():
45
+ x, y = bottom_left
46
+ else:
47
+ x, y = top_left
48
+ x = max(0, min(surface.get_width() - padded.width, x))
49
+ y = max(0, min(surface.get_height() - padded.height, y))
50
+ padded.topleft = (x, y)
51
+ label_rect.center = padded.center
52
+ surface.blit(label, label_rect)
53
+
54
+
27
55
  def draw_level_overview(
28
56
  assets: RenderAssets,
29
57
  surface: surface.Surface,
@@ -39,12 +67,14 @@ def draw_level_overview(
39
67
  shoes: list[Shoes] | None = None,
40
68
  buddies: list[Survivor] | None = None,
41
69
  survivors: list[Survivor] | None = None,
70
+ fall_spawn_cells: set[tuple[int, int]] | None = None,
42
71
  palette_key: str | None = None,
43
72
  ) -> None:
44
73
  palette = get_environment_palette(palette_key)
45
74
  base_floor = palette.floor_primary
46
75
  dark_floor = tuple(max(0, int(channel * 0.35)) for channel in base_floor)
47
76
  floor_color = tuple(max(0, int(channel * 0.65)) for channel in base_floor)
77
+ fall_floor = tuple(max(0, int(channel * 0.55)) for channel in palette.fall_zone_primary)
48
78
  surface.fill(dark_floor)
49
79
  cell_size = assets.internal_wall_grid_snap
50
80
  if cell_size > 0:
@@ -59,6 +89,18 @@ def draw_level_overview(
59
89
  cell_size,
60
90
  ),
61
91
  )
92
+ if fall_spawn_cells:
93
+ for x, y in fall_spawn_cells:
94
+ pygame.draw.rect(
95
+ surface,
96
+ fall_floor,
97
+ pygame.Rect(
98
+ x * cell_size,
99
+ y * cell_size,
100
+ cell_size,
101
+ cell_size,
102
+ ),
103
+ )
62
104
 
63
105
  for wall in wall_group:
64
106
  if wall.max_health > 0:
@@ -179,6 +221,7 @@ def draw_debug_overview(
179
221
  if survivor.alive() and survivor.is_buddy and not survivor.rescued
180
222
  ],
181
223
  survivors=list(game_data.groups.survivor_group),
224
+ fall_spawn_cells=game_data.layout.fall_spawn_cells,
182
225
  palette_key=game_data.state.ambient_palette_key,
183
226
  )
184
227
  zombie_color = (200, 80, 80)
@@ -191,6 +234,21 @@ def draw_debug_overview(
191
234
  zombie.rect.center,
192
235
  zombie_radius,
193
236
  )
237
+ fov_target = None
238
+ if game_data.player and game_data.player.in_car and game_data.car and game_data.car.alive():
239
+ fov_target = game_data.car
240
+ elif game_data.player:
241
+ fov_target = game_data.player
242
+ if fov_target:
243
+ fov_scale = _get_fog_scale(assets, game_data.state.flashlight_count)
244
+ fov_radius = max(1, int(assets.fov_radius * fov_scale))
245
+ pygame.draw.circle(
246
+ overview_surface,
247
+ (255, 255, 120),
248
+ fov_target.rect.center,
249
+ fov_radius,
250
+ width=2,
251
+ )
194
252
  cam_offset = game_data.camera.camera
195
253
  camera_rect = pygame.Rect(
196
254
  -cam_offset.x,
@@ -212,7 +270,40 @@ def draw_debug_overview(
212
270
  scaled_h = max(1, scaled_h)
213
271
  scaled_overview = pygame.transform.smoothscale(overview_surface, (scaled_w, scaled_h))
214
272
  screen.fill(BLACK)
273
+ scaled_rect = scaled_overview.get_rect(center=(screen_width // 2, screen_height // 2))
215
274
  screen.blit(
216
275
  scaled_overview,
217
- scaled_overview.get_rect(center=(screen_width // 2, screen_height // 2)),
276
+ scaled_rect,
218
277
  )
278
+ try:
279
+ font_settings = get_font_settings()
280
+ label_font = load_font(font_settings.resource, font_settings.scaled_size(11))
281
+ except pygame.error as e:
282
+ print(f"Error loading overview font: {e}")
283
+ return
284
+ scale_x = scaled_w / max(1, level_rect.width)
285
+ scale_y = scaled_h / max(1, level_rect.height)
286
+
287
+ def _scaled_rect(rect: pygame.Rect) -> pygame.Rect:
288
+ return pygame.Rect(
289
+ int(scaled_rect.left + rect.left * scale_x),
290
+ int(scaled_rect.top + rect.top * scale_y),
291
+ max(1, int(rect.width * scale_x)),
292
+ max(1, int(rect.height * scale_y)),
293
+ )
294
+
295
+ if game_data.car and game_data.car.alive():
296
+ _draw_overview_tag(screen, label_font, "C", _scaled_rect(game_data.car.rect))
297
+ for parked in game_data.waiting_cars:
298
+ if parked.alive():
299
+ _draw_overview_tag(screen, label_font, "C", _scaled_rect(parked.rect))
300
+ if game_data.fuel and game_data.fuel.alive():
301
+ _draw_overview_tag(screen, label_font, "F", _scaled_rect(game_data.fuel.rect))
302
+ if game_data.flashlights:
303
+ for flashlight in game_data.flashlights:
304
+ if flashlight.alive():
305
+ _draw_overview_tag(screen, label_font, "L", _scaled_rect(flashlight.rect))
306
+ if game_data.shoes:
307
+ for item in game_data.shoes:
308
+ if item.alive():
309
+ _draw_overview_tag(screen, label_font, "S", _scaled_rect(item.rect))
@@ -22,7 +22,7 @@ _SHADOW_LAYER_CACHE: dict[tuple[int, int], surface.Surface] = {}
22
22
  _SHADOW_CIRCLE_CACHE: dict[tuple[int, int, float], surface.Surface] = {}
23
23
 
24
24
 
25
- def _get_shadow_tile_surface(
25
+ def _get_shadow_cell_surface(
26
26
  cell_size: int,
27
27
  alpha: int,
28
28
  *,
@@ -176,7 +176,7 @@ def _draw_wall_shadows(
176
176
  return False
177
177
  base_shadow_size = max(cell_size + 2, int(cell_size * 1.35))
178
178
  shadow_size = max(1, int(base_shadow_size * 1.5))
179
- shadow_surface = _get_shadow_tile_surface(
179
+ shadow_surface = _get_shadow_cell_surface(
180
180
  shadow_size,
181
181
  alpha,
182
182
  edge_softness=0.12,
@@ -20,6 +20,12 @@ FALLING_WHIRLWIND_COLOR = (200, 200, 200, 120)
20
20
  FALLING_DUST_COLOR = (70, 70, 70, 130)
21
21
  ANGLE_BINS = 16
22
22
  HAND_SPREAD_RAD = math.radians(75)
23
+ GAMEPLAY_FONT_SIZE = 11
24
+ HUD_ICON_SIZE = 12
25
+ FADE_IN_DURATION_MS = 900
26
+ TIMED_MESSAGE_LEFT_X = 20
27
+ TIMED_MESSAGE_TOP_Y = 48
28
+ TIMED_MESSAGE_BAND_ALPHA = 80
23
29
 
24
30
 
25
31
  @dataclass(frozen=True)
@@ -113,6 +119,12 @@ __all__ = [
113
119
  "ZOMBIE_NOSE_COLOR",
114
120
  "ANGLE_BINS",
115
121
  "HAND_SPREAD_RAD",
122
+ "GAMEPLAY_FONT_SIZE",
123
+ "HUD_ICON_SIZE",
124
+ "FADE_IN_DURATION_MS",
125
+ "TIMED_MESSAGE_LEFT_X",
126
+ "TIMED_MESSAGE_TOP_Y",
127
+ "TIMED_MESSAGE_BAND_ALPHA",
116
128
  "HUMANOID_OUTLINE_COLOR",
117
129
  "HUMANOID_OUTLINE_WIDTH",
118
130
  "SURVIVOR_COLOR",
@@ -10,21 +10,10 @@ from dataclasses import dataclass
10
10
  from enum import Enum
11
11
  from typing import TYPE_CHECKING
12
12
 
13
- import pygame
14
- from pygame import surface
15
-
16
13
  try: # pragma: no cover - version fallback not critical for tests
17
14
  from ..__about__ import __version__
18
15
  except Exception: # pragma: no cover - fallback version
19
16
  __version__ = "0.0.0-unknown"
20
- from ..screen_constants import (
21
- DEFAULT_WINDOW_SCALE,
22
- SCREEN_HEIGHT,
23
- SCREEN_WIDTH,
24
- WINDOW_SCALE_MAX,
25
- WINDOW_SCALE_MIN,
26
- )
27
-
28
17
  if TYPE_CHECKING: # pragma: no cover - typing only
29
18
  from ..models import GameData, Stage
30
19
 
@@ -52,186 +41,14 @@ class ScreenTransition:
52
41
  seed_is_auto: bool = False
53
42
 
54
43
 
55
- current_window_scale = DEFAULT_WINDOW_SCALE # Applied to the OS window only
56
- current_maximized = False
57
- last_window_scale = DEFAULT_WINDOW_SCALE
58
- current_window_size = (
59
- int(SCREEN_WIDTH * DEFAULT_WINDOW_SCALE),
60
- int(SCREEN_HEIGHT * DEFAULT_WINDOW_SCALE),
61
- )
62
- last_logged_window_size = current_window_size
63
-
64
44
  __all__ = [
65
45
  "ScreenID",
66
46
  "ScreenTransition",
67
- "present",
68
- "apply_window_scale",
69
- "nudge_window_scale",
70
- "toggle_fullscreen",
71
- "sync_window_size",
47
+ "TITLE_FONT_SCALE",
48
+ "TITLE_HEADER_Y",
49
+ "TITLE_SECTION_TOP",
72
50
  ]
73
51
 
74
-
75
- def present(logical_surface: surface.Surface) -> None:
76
- """Scale the logical surface directly to the window and flip buffers."""
77
- window = pygame.display.get_surface()
78
- if window is None:
79
- return
80
- window_size = _fetch_window_size(window)
81
- _update_window_size(window_size, source="frame")
82
- logical_size = logical_surface.get_size()
83
- if window_size == logical_size:
84
- window.blit(logical_surface, (0, 0))
85
- else:
86
- # Preserve aspect ratio with letterboxing.
87
- scale_x = window_size[0] / max(1, logical_size[0])
88
- scale_y = window_size[1] / max(1, logical_size[1])
89
- scale = min(scale_x, scale_y)
90
- scaled_width = max(1, int(logical_size[0] * scale))
91
- scaled_height = max(1, int(logical_size[1] * scale))
92
- window.fill((0, 0, 0))
93
- if (scaled_width, scaled_height) == logical_size:
94
- scaled_surface = logical_surface
95
- else:
96
- scaled_surface = pygame.transform.scale(logical_surface, (scaled_width, scaled_height))
97
- offset_x = (window_size[0] - scaled_width) // 2
98
- offset_y = (window_size[1] - scaled_height) // 2
99
- window.blit(scaled_surface, (offset_x, offset_y))
100
- pygame.display.flip()
101
-
102
-
103
- def apply_window_scale(scale: float, *, game_data: "GameData | None" = None) -> surface.Surface:
104
- """Resize the OS window; logical render surface stays constant."""
105
- global current_window_scale, current_maximized, last_window_scale
106
-
107
- clamped_scale = max(WINDOW_SCALE_MIN, min(WINDOW_SCALE_MAX, scale))
108
- current_window_scale = clamped_scale
109
- last_window_scale = clamped_scale
110
- current_maximized = False
111
-
112
- window_width = max(1, int(SCREEN_WIDTH * current_window_scale))
113
- window_height = max(1, int(SCREEN_HEIGHT * current_window_scale))
114
-
115
- new_window = pygame.display.set_mode((window_width, window_height), pygame.RESIZABLE)
116
- _update_window_size((window_width, window_height), source="apply_scale")
117
- _update_window_caption()
118
-
119
- if game_data is not None:
120
- game_data.state.overview_created = False
121
-
122
- return new_window
123
-
124
-
125
- def nudge_window_scale(multiplier: float, *, game_data: "GameData | None" = None) -> surface.Surface:
126
- """Scale the window relative to the current zoom level."""
127
- target_scale = current_window_scale * multiplier
128
- return apply_window_scale(target_scale, game_data=game_data)
129
-
130
-
131
- def toggle_fullscreen(*, game_data: "GameData | None" = None) -> surface.Surface | None:
132
- """Toggle a maximized window without persisting the setting."""
133
- global current_maximized, last_window_scale
134
- if current_maximized:
135
- current_maximized = False
136
- window_width = max(1, int(SCREEN_WIDTH * last_window_scale))
137
- window_height = max(1, int(SCREEN_HEIGHT * last_window_scale))
138
- window = pygame.display.set_mode((window_width, window_height), pygame.RESIZABLE)
139
- _restore_window()
140
- _update_window_caption()
141
- _update_window_size((window_width, window_height), source="toggle_windowed")
142
- else:
143
- last_window_scale = current_window_scale
144
- current_maximized = True
145
- window = pygame.display.set_mode(_fetch_window_size(None), pygame.RESIZABLE)
146
- _maximize_window()
147
- window_width, window_height = _fetch_window_size(window)
148
- _update_window_caption()
149
- _update_window_size((window_width, window_height), source="toggle_fullscreen")
150
- pygame.mouse.set_visible(not current_maximized)
151
- if game_data is not None:
152
- game_data.state.overview_created = False
153
- return window
154
-
155
-
156
- def sync_window_size(event: pygame.event.Event, *, game_data: "GameData | None" = None) -> None:
157
- """Synchronize tracked window size with SDL window events."""
158
- global current_window_scale, last_window_scale
159
- size = getattr(event, "size", None)
160
- if not size:
161
- width = getattr(event, "x", None)
162
- height = getattr(event, "y", None)
163
- if width is not None and height is not None:
164
- size = (width, height)
165
- if not size:
166
- return
167
- window_width, window_height = _normalize_window_size(size)
168
- _update_window_size((window_width, window_height), source="window_event")
169
- if not current_maximized:
170
- scale_x = window_width / max(1, SCREEN_WIDTH)
171
- scale_y = window_height / max(1, SCREEN_HEIGHT)
172
- scale = max(WINDOW_SCALE_MIN, min(WINDOW_SCALE_MAX, min(scale_x, scale_y)))
173
- current_window_scale = scale
174
- last_window_scale = scale
175
- _update_window_caption()
176
- if game_data is not None:
177
- game_data.state.overview_created = False
178
-
179
-
180
- def _fetch_window_size(window: surface.Surface | None) -> tuple[int, int]:
181
- if hasattr(pygame.display, "get_window_size"):
182
- size = pygame.display.get_window_size()
183
- if size != (0, 0):
184
- return _normalize_window_size(size)
185
- if window is not None:
186
- return _normalize_window_size(window.get_size())
187
- window_width = max(1, int(SCREEN_WIDTH * last_window_scale))
188
- window_height = max(1, int(SCREEN_HEIGHT * last_window_scale))
189
- return window_width, window_height
190
-
191
-
192
- def _normalize_window_size(size: tuple[int, int]) -> tuple[int, int]:
193
- width = max(1, int(size[0]))
194
- height = max(1, int(size[1]))
195
- return width, height
196
-
197
-
198
- def _update_window_size(size: tuple[int, int], *, source: str) -> None:
199
- global current_window_size, last_logged_window_size
200
- current_window_size = size
201
- if size != last_logged_window_size:
202
- print(f"WINDOW_SIZE {source}={size[0]}x{size[1]}")
203
- last_logged_window_size = size
204
-
205
-
206
- def _update_window_caption() -> None:
207
- pygame.display.set_caption("Zombie Escape")
208
-
209
-
210
- def _maximize_window() -> None:
211
- try:
212
- from pygame import _sdl2 as sdl2 # type: ignore[import-not-found]
213
- except Exception:
214
- return
215
- try:
216
- window = sdl2.Window.from_display_module()
217
- except Exception:
218
- return
219
- try:
220
- window.maximize()
221
- except Exception:
222
- return
223
-
224
-
225
- def _restore_window() -> None:
226
- try:
227
- from pygame import _sdl2 as sdl2 # type: ignore[import-not-found]
228
- except Exception:
229
- return
230
- try:
231
- window = sdl2.Window.from_display_module()
232
- except Exception:
233
- return
234
- try:
235
- window.restore()
236
- except Exception:
237
- return
52
+ TITLE_FONT_SCALE = 1
53
+ TITLE_HEADER_Y = 20
54
+ TITLE_SECTION_TOP = 45