zombie-escape 1.7.1__py3-none-any.whl → 1.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. zombie_escape/__about__.py +1 -1
  2. zombie_escape/colors.py +41 -8
  3. zombie_escape/entities.py +376 -306
  4. zombie_escape/entities_constants.py +6 -0
  5. zombie_escape/gameplay/__init__.py +2 -2
  6. zombie_escape/gameplay/constants.py +1 -7
  7. zombie_escape/gameplay/footprints.py +2 -2
  8. zombie_escape/gameplay/interactions.py +4 -10
  9. zombie_escape/gameplay/layout.py +43 -4
  10. zombie_escape/gameplay/movement.py +45 -7
  11. zombie_escape/gameplay/spawn.py +283 -43
  12. zombie_escape/gameplay/state.py +19 -16
  13. zombie_escape/gameplay/survivors.py +47 -15
  14. zombie_escape/gameplay/utils.py +19 -1
  15. zombie_escape/input_utils.py +167 -0
  16. zombie_escape/level_blueprints.py +28 -0
  17. zombie_escape/locales/ui.en.json +55 -11
  18. zombie_escape/locales/ui.ja.json +54 -10
  19. zombie_escape/localization.py +28 -0
  20. zombie_escape/models.py +54 -7
  21. zombie_escape/render.py +704 -267
  22. zombie_escape/render_constants.py +12 -0
  23. zombie_escape/screens/__init__.py +1 -0
  24. zombie_escape/screens/game_over.py +8 -4
  25. zombie_escape/screens/gameplay.py +88 -41
  26. zombie_escape/screens/settings.py +124 -13
  27. zombie_escape/screens/title.py +111 -0
  28. zombie_escape/stage_constants.py +116 -3
  29. zombie_escape/world_grid.py +134 -0
  30. zombie_escape/zombie_escape.py +68 -61
  31. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/METADATA +11 -3
  32. zombie_escape-1.10.0.dist-info/RECORD +47 -0
  33. zombie_escape-1.7.1.dist-info/RECORD +0 -45
  34. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/WHEEL +0 -0
  35. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/entry_points.txt +0 -0
  36. {zombie_escape-1.7.1.dist-info → zombie_escape-1.10.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -11,6 +11,9 @@ HUMANOID_OUTLINE_COLOR = (0, 80, 200)
11
11
  HUMANOID_OUTLINE_WIDTH = 1
12
12
  BUDDY_COLOR = (0, 180, 63)
13
13
  SURVIVOR_COLOR = (198, 198, 198)
14
+ FALLING_ZOMBIE_COLOR = (45, 45, 45)
15
+ FALLING_WHIRLWIND_COLOR = (200, 200, 200, 120)
16
+ FALLING_DUST_COLOR = (70, 70, 70, 130)
14
17
 
15
18
 
16
19
  @dataclass(frozen=True)
@@ -35,11 +38,14 @@ class RenderAssets:
35
38
  footprint_min_fade: float
36
39
  internal_wall_grid_snap: int
37
40
  flashlight_bonus_step: float
41
+ flashlight_hatch_extra_scale: float
42
+
38
43
 
39
44
  FOG_RADIUS_SCALE = 1.2
40
45
  FOG_HATCH_PIXEL_SCALE = 2
41
46
 
42
47
  FLASHLIGHT_FOG_SCALE_STEP = 0.3
48
+ FLASHLIGHT_HATCH_EXTRA_SCALE = 0.12
43
49
 
44
50
  FOOTPRINT_RADIUS = 2
45
51
  FOOTPRINT_OVERVIEW_RADIUS = 3
@@ -55,6 +61,7 @@ FOG_RINGS = [
55
61
  FogRing(radius_factor=0.968, thickness=12),
56
62
  ]
57
63
 
64
+
58
65
  def build_render_assets(cell_size: int) -> RenderAssets:
59
66
  return RenderAssets(
60
67
  screen_width=SCREEN_WIDTH,
@@ -71,11 +78,15 @@ def build_render_assets(cell_size: int) -> RenderAssets:
71
78
  footprint_min_fade=FOOTPRINT_MIN_FADE,
72
79
  internal_wall_grid_snap=cell_size,
73
80
  flashlight_bonus_step=FLASHLIGHT_FOG_SCALE_STEP,
81
+ flashlight_hatch_extra_scale=FLASHLIGHT_HATCH_EXTRA_SCALE,
74
82
  )
75
83
 
76
84
 
77
85
  __all__ = [
78
86
  "BUDDY_COLOR",
87
+ "FALLING_ZOMBIE_COLOR",
88
+ "FALLING_WHIRLWIND_COLOR",
89
+ "FALLING_DUST_COLOR",
79
90
  "HUMANOID_OUTLINE_COLOR",
80
91
  "HUMANOID_OUTLINE_WIDTH",
81
92
  "SURVIVOR_COLOR",
@@ -83,5 +94,6 @@ __all__ = [
83
94
  "RenderAssets",
84
95
  "FOG_RADIUS_SCALE",
85
96
  "FLASHLIGHT_FOG_SCALE_STEP",
97
+ "FLASHLIGHT_HATCH_EXTRA_SCALE",
86
98
  "build_render_assets",
87
99
  ]
@@ -159,6 +159,7 @@ def toggle_fullscreen(
159
159
  window_width, window_height = _fetch_window_size(window)
160
160
  _update_window_caption(window_width, window_height)
161
161
  _update_window_size((window_width, window_height), source="toggle_fullscreen")
162
+ pygame.mouse.set_visible(not current_maximized)
162
163
  if game_data is not None:
163
164
  game_data.state.overview_created = False
164
165
  return window
@@ -14,6 +14,7 @@ from ..render import (
14
14
  _draw_status_bar,
15
15
  show_message,
16
16
  )
17
+ from ..input_utils import is_confirm_event, is_select_event
17
18
  from ..screens import (
18
19
  ScreenID,
19
20
  ScreenTransition,
@@ -129,16 +130,16 @@ def game_over_screen(
129
130
  LIGHT_GRAY,
130
131
  (screen_width // 2, summary_y),
131
132
  )
132
- elif stage and stage.survival_stage:
133
- elapsed_ms = max(0, state.survival_elapsed_ms)
134
- goal_ms = max(0, state.survival_goal_ms)
133
+ elif stage and stage.endurance_stage:
134
+ elapsed_ms = max(0, state.endurance_elapsed_ms)
135
+ goal_ms = max(0, state.endurance_goal_ms)
135
136
  if goal_ms:
136
137
  elapsed_ms = min(elapsed_ms, goal_ms)
137
138
  display_ms = int(elapsed_ms * SURVIVAL_FAKE_CLOCK_RATIO)
138
139
  hours = display_ms // 3_600_000
139
140
  minutes = (display_ms % 3_600_000) // 60_000
140
141
  time_label = f"{int(hours):02d}:{int(minutes):02d}"
141
- msg = tr("game_over.survival_duration", time=time_label)
142
+ msg = tr("game_over.endurance_duration", time=time_label)
142
143
  show_message(
143
144
  screen,
144
145
  msg,
@@ -181,3 +182,6 @@ def game_over_screen(
181
182
  stage=stage,
182
183
  seed=state.seed,
183
184
  )
185
+ if event.type in (pygame.CONTROLLERBUTTONDOWN, pygame.JOYBUTTONDOWN):
186
+ if is_select_event(event) or is_confirm_event(event):
187
+ return ScreenTransition(ScreenID.TITLE)
@@ -8,7 +8,6 @@ from pygame import surface, time
8
8
  from ..colors import LIGHT_GRAY, RED, WHITE, YELLOW
9
9
  from ..gameplay_constants import (
10
10
  CAR_HINT_DELAY_MS_DEFAULT,
11
- DEFAULT_FLASHLIGHT_SPAWN_COUNT,
12
11
  SURVIVAL_TIME_ACCEL_SUBSTEPS,
13
12
  SURVIVAL_TIME_ACCEL_MAX_SUBSTEP,
14
13
  )
@@ -29,10 +28,21 @@ from ..gameplay import (
29
28
  sync_ambient_palette_with_flashlights,
30
29
  update_entities,
31
30
  update_footprints,
32
- update_survival_timer,
31
+ update_endurance_timer,
32
+ )
33
+ from ..input_utils import (
34
+ CONTROLLER_BUTTON_DOWN,
35
+ CONTROLLER_DEVICE_ADDED,
36
+ CONTROLLER_DEVICE_REMOVED,
37
+ init_first_controller,
38
+ init_first_joystick,
39
+ is_accel_active,
40
+ is_select_event,
41
+ is_start_event,
42
+ read_gamepad_move,
33
43
  )
34
44
  from ..gameplay.spawn import _alive_waiting_cars
35
- from ..entities import build_wall_index
45
+ from ..world_grid import build_wall_index
36
46
  from ..localization import translate as tr
37
47
  from ..models import Stage
38
48
  from ..render import draw, prewarm_fog_overlays, show_message
@@ -72,11 +82,11 @@ def gameplay_screen(
72
82
  game_data = initialize_game_state(config, stage)
73
83
  game_data.state.seed = applied_seed
74
84
  game_data.state.debug_mode = debug_mode
75
- if debug_mode and stage.survival_stage:
76
- goal_ms = max(0, stage.survival_goal_ms)
85
+ if debug_mode and stage.endurance_stage:
86
+ goal_ms = max(0, stage.endurance_goal_ms)
77
87
  if goal_ms > 0:
78
88
  remaining = 3 * 60 * 1000 # 3 minutes in ms
79
- game_data.state.survival_elapsed_ms = max(0, goal_ms - remaining)
89
+ game_data.state.endurance_elapsed_ms = max(0, goal_ms - remaining)
80
90
  game_data.state.dawn_ready = False
81
91
  game_data.state.dawn_prompt_at = None
82
92
  game_data.state.dawn_carbonized = False
@@ -88,7 +98,8 @@ def gameplay_screen(
88
98
  paused_manual = False
89
99
  paused_focus = False
90
100
  ignore_focus_loss_until = 0
91
- last_fov_target = None
101
+ controller = init_first_controller()
102
+ joystick = init_first_joystick() if controller is None else None
92
103
 
93
104
  layout_data = generate_level_from_blueprint(game_data, config)
94
105
  sync_ambient_palette_with_flashlights(game_data, force=True)
@@ -118,19 +129,18 @@ def gameplay_screen(
118
129
  if fuel_can:
119
130
  game_data.fuel = fuel_can
120
131
  game_data.groups.all_sprites.add(fuel_can, layer=1)
132
+ flashlight_count = stage.initial_flashlight_count
121
133
  flashlights = place_flashlights(
122
134
  layout_data["walkable_cells"],
123
135
  player,
124
136
  cars=game_data.waiting_cars,
125
- count=max(1, DEFAULT_FLASHLIGHT_SPAWN_COUNT),
137
+ count=max(0, flashlight_count),
126
138
  )
127
139
  game_data.flashlights = flashlights
128
140
  game_data.groups.all_sprites.add(flashlights, layer=1)
129
141
 
130
142
  spawn_initial_zombies(game_data, player, layout_data, config)
131
143
  update_footprints(game_data, config)
132
- last_fov_target = player
133
-
134
144
  while True:
135
145
  dt = clock.tick(fps) / 1000.0
136
146
  if game_data.state.game_over or game_data.state.game_won:
@@ -144,7 +154,6 @@ def gameplay_screen(
144
154
  render_assets,
145
155
  screen,
146
156
  game_data,
147
- last_fov_target,
148
157
  config=config,
149
158
  hint_color=None,
150
159
  present_fn=present,
@@ -180,7 +189,22 @@ def gameplay_screen(
180
189
  paused_focus = False
181
190
  if event.type == pygame.MOUSEBUTTONDOWN:
182
191
  paused_focus = False
183
- paused_manual = False
192
+ if event.type == pygame.JOYDEVICEADDED or (
193
+ CONTROLLER_DEVICE_ADDED is not None
194
+ and event.type == CONTROLLER_DEVICE_ADDED
195
+ ):
196
+ if controller is None:
197
+ controller = init_first_controller()
198
+ if controller is None:
199
+ joystick = init_first_joystick()
200
+ if event.type == pygame.JOYDEVICEREMOVED or (
201
+ CONTROLLER_DEVICE_REMOVED is not None
202
+ and event.type == CONTROLLER_DEVICE_REMOVED
203
+ ):
204
+ if controller and not controller.get_init():
205
+ controller = None
206
+ if joystick and not joystick.get_init():
207
+ joystick = None
184
208
  if event.type == pygame.KEYDOWN:
185
209
  if event.key == pygame.K_s and (
186
210
  pygame.key.get_mods() & pygame.KMOD_CTRL
@@ -192,10 +216,40 @@ def gameplay_screen(
192
216
  }
193
217
  print("STATE DEBUG:", state_snapshot)
194
218
  continue
195
- if event.key == pygame.K_ESCAPE:
196
- return ScreenTransition(ScreenID.TITLE)
197
- if event.key == pygame.K_p:
198
- paused_manual = not paused_manual
219
+ if debug_mode:
220
+ if event.key == pygame.K_ESCAPE:
221
+ return ScreenTransition(ScreenID.TITLE)
222
+ if event.key == pygame.K_p:
223
+ paused_manual = not paused_manual
224
+ continue
225
+ if paused_manual:
226
+ if event.key == pygame.K_ESCAPE:
227
+ return ScreenTransition(ScreenID.TITLE)
228
+ if event.key == pygame.K_p:
229
+ paused_manual = False
230
+ continue
231
+ if event.key in (pygame.K_ESCAPE, pygame.K_p):
232
+ paused_manual = True
233
+ continue
234
+ if event.type == pygame.JOYBUTTONDOWN or (
235
+ CONTROLLER_BUTTON_DOWN is not None
236
+ and event.type == CONTROLLER_BUTTON_DOWN
237
+ ):
238
+ if debug_mode:
239
+ if is_select_event(event):
240
+ return ScreenTransition(ScreenID.TITLE)
241
+ if is_start_event(event):
242
+ paused_manual = not paused_manual
243
+ continue
244
+ if paused_manual:
245
+ if is_select_event(event):
246
+ return ScreenTransition(ScreenID.TITLE)
247
+ if is_start_event(event):
248
+ paused_manual = False
249
+ continue
250
+ if is_select_event(event) or is_start_event(event):
251
+ paused_manual = True
252
+ continue
199
253
 
200
254
  paused = paused_manual or paused_focus
201
255
  if paused:
@@ -203,7 +257,6 @@ def gameplay_screen(
203
257
  render_assets,
204
258
  screen,
205
259
  game_data,
206
- last_fov_target,
207
260
  config=config,
208
261
  do_flip=not show_pause_overlay,
209
262
  present_fn=present,
@@ -211,17 +264,19 @@ def gameplay_screen(
211
264
  if show_pause_overlay:
212
265
  overlay = pygame.Surface((screen_width, screen_height), pygame.SRCALPHA)
213
266
  overlay.fill((0, 0, 0, 150))
267
+ pause_radius = 53
268
+ cx = screen_width // 2
269
+ cy = screen_height // 2 - 18
214
270
  pygame.draw.circle(
215
271
  overlay,
216
272
  LIGHT_GRAY,
217
- (screen_width // 2, screen_height // 2),
218
- 35,
273
+ (cx, cy),
274
+ pause_radius,
219
275
  width=3,
220
276
  )
221
- bar_width = 8
222
- bar_height = 30
223
- gap = 9
224
- cx, cy = screen_width // 2, screen_height // 2
277
+ bar_width = 10
278
+ bar_height = 38
279
+ gap = 12
225
280
  pygame.draw.rect(
226
281
  overlay,
227
282
  LIGHT_GRAY,
@@ -238,12 +293,12 @@ def gameplay_screen(
238
293
  tr("hud.paused"),
239
294
  18,
240
295
  WHITE,
241
- (screen_width // 2, screen_height // 2 + 24),
296
+ (screen_width // 2, 28),
242
297
  )
243
298
  show_message(
244
299
  screen,
245
- tr("hud.resume_hint"),
246
- 18,
300
+ tr("hud.pause_hint"),
301
+ 16,
247
302
  LIGHT_GRAY,
248
303
  (screen_width // 2, screen_height // 2 + 70),
249
304
  )
@@ -254,8 +309,8 @@ def gameplay_screen(
254
309
  accel_allowed = not (
255
310
  game_data.state.game_over or game_data.state.game_won
256
311
  )
257
- accel_active = accel_allowed and (
258
- keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT]
312
+ accel_active = accel_allowed and is_accel_active(
313
+ keys, controller, joystick
259
314
  )
260
315
  game_data.state.time_accel_active = accel_active
261
316
  substeps = SURVIVAL_TIME_ACCEL_SUBSTEPS if accel_active else 1
@@ -265,14 +320,14 @@ def gameplay_screen(
265
320
  wall_index = build_wall_index(
266
321
  game_data.groups.wall_group, cell_size=game_data.cell_size
267
322
  )
268
- frame_fov_target = None
269
323
  for _ in range(substeps):
270
324
  player_ref = game_data.player
271
325
  if player_ref is None:
272
326
  break
273
327
  car_ref = game_data.car
328
+ pad_vector = read_gamepad_move(controller, joystick)
274
329
  player_dx, player_dy, car_dx, car_dy = process_player_input(
275
- keys, player_ref, car_ref
330
+ keys, player_ref, car_ref, pad_input=pad_vector
276
331
  )
277
332
  update_entities(
278
333
  game_data,
@@ -288,19 +343,12 @@ def gameplay_screen(
288
343
  if accel_active:
289
344
  step_ms = max(1, step_ms)
290
345
  game_data.state.elapsed_play_ms += step_ms
291
- update_survival_timer(game_data, step_ms)
346
+ update_endurance_timer(game_data, step_ms)
292
347
  cleanup_survivor_messages(game_data.state)
293
- sub_fov_target = check_interactions(game_data, config)
294
- if sub_fov_target:
295
- frame_fov_target = sub_fov_target
348
+ check_interactions(game_data, config)
296
349
  if game_data.state.game_over or game_data.state.game_won:
297
350
  break
298
351
 
299
- if frame_fov_target:
300
- last_fov_target = frame_fov_target
301
- else:
302
- frame_fov_target = last_fov_target
303
-
304
352
  player = game_data.player
305
353
  if player is None:
306
354
  raise ValueError("Player missing from game data")
@@ -309,7 +357,7 @@ def gameplay_screen(
309
357
  hint_delay = car_hint_conf.get("delay_ms", CAR_HINT_DELAY_MS_DEFAULT)
310
358
  elapsed_ms = game_data.state.elapsed_play_ms
311
359
  has_fuel = game_data.state.has_fuel
312
- hint_enabled = car_hint_conf.get("enabled", True) and not stage.survival_stage
360
+ hint_enabled = car_hint_conf.get("enabled", True) and not stage.endurance_stage
313
361
  hint_target = None
314
362
  hint_color = YELLOW
315
363
  hint_expires_at = game_data.state.hint_expires_at
@@ -356,7 +404,6 @@ def gameplay_screen(
356
404
  render_assets,
357
405
  screen,
358
406
  game_data,
359
- frame_fov_target,
360
407
  config=config,
361
408
  hint_target=hint_target,
362
409
  hint_color=hint_color,
@@ -10,6 +10,19 @@ from pygame import surface, time
10
10
  from ..colors import BLACK, GREEN, LIGHT_GRAY, WHITE
11
11
  from ..config import DEFAULT_CONFIG, save_config
12
12
  from ..font_utils import load_font
13
+ from ..input_utils import (
14
+ CONTROLLER_BUTTON_DOWN,
15
+ CONTROLLER_BUTTON_DPAD_DOWN,
16
+ CONTROLLER_BUTTON_DPAD_LEFT,
17
+ CONTROLLER_BUTTON_DPAD_RIGHT,
18
+ CONTROLLER_BUTTON_DPAD_UP,
19
+ CONTROLLER_DEVICE_ADDED,
20
+ CONTROLLER_DEVICE_REMOVED,
21
+ init_first_controller,
22
+ init_first_joystick,
23
+ is_confirm_event,
24
+ is_select_event,
25
+ )
13
26
  from ..localization import (
14
27
  get_font_settings,
15
28
  get_language,
@@ -95,6 +108,8 @@ def settings_screen(
95
108
  selected = 0
96
109
  languages = language_options()
97
110
  language_codes = [lang.code for lang in languages]
111
+ controller = init_first_controller()
112
+ joystick = init_first_joystick() if controller is None else None
98
113
 
99
114
  def _ensure_parent(path: tuple[str, ...]) -> tuple[dict, str]:
100
115
  node = working
@@ -143,6 +158,15 @@ def settings_screen(
143
158
 
144
159
  def build_sections() -> list[dict]:
145
160
  return [
161
+ {
162
+ "label": tr("settings.sections.menu"),
163
+ "rows": [
164
+ {
165
+ "type": "action",
166
+ "label": tr("settings.rows.return_to_title"),
167
+ }
168
+ ],
169
+ },
146
170
  {
147
171
  "label": tr("settings.sections.localization"),
148
172
  "rows": [
@@ -210,6 +234,10 @@ def settings_screen(
210
234
  row_count = len(rows)
211
235
  last_language = get_language()
212
236
 
237
+ def _exit_settings() -> dict[str, Any]:
238
+ save_config(working, config_path)
239
+ return working
240
+
213
241
  while True:
214
242
  for event in pygame.event.get():
215
243
  if event.type == pygame.QUIT:
@@ -217,6 +245,24 @@ def settings_screen(
217
245
  if event.type in (pygame.WINDOWSIZECHANGED, pygame.VIDEORESIZE):
218
246
  sync_window_size(event)
219
247
  continue
248
+ if event.type == pygame.JOYDEVICEADDED or (
249
+ CONTROLLER_DEVICE_ADDED is not None
250
+ and event.type == CONTROLLER_DEVICE_ADDED
251
+ ):
252
+ if controller is None:
253
+ controller = init_first_controller()
254
+ if controller is None:
255
+ joystick = init_first_joystick()
256
+ if event.type == pygame.JOYDEVICEREMOVED or (
257
+ CONTROLLER_DEVICE_REMOVED is not None
258
+ and event.type == CONTROLLER_DEVICE_REMOVED
259
+ ):
260
+ if controller and not controller.get_init():
261
+ controller = None
262
+ if joystick and not joystick.get_init():
263
+ joystick = None
264
+ if is_select_event(event):
265
+ return _exit_settings()
220
266
  if event.type == pygame.KEYDOWN:
221
267
  if event.key == pygame.K_LEFTBRACKET:
222
268
  nudge_window_scale(0.5)
@@ -228,8 +274,7 @@ def settings_screen(
228
274
  toggle_fullscreen()
229
275
  continue
230
276
  if event.key in (pygame.K_ESCAPE, pygame.K_BACKSPACE):
231
- save_config(working, config_path)
232
- return working
277
+ return _exit_settings()
233
278
  if event.key in (pygame.K_UP, pygame.K_w):
234
279
  selected = (selected - 1) % row_count
235
280
  if event.key in (pygame.K_DOWN, pygame.K_s):
@@ -237,16 +282,18 @@ def settings_screen(
237
282
  current_row = rows[selected]
238
283
  row_type = current_row.get("type", "toggle")
239
284
  if event.key in (pygame.K_SPACE, pygame.K_RETURN):
285
+ if row_type == "action":
286
+ return _exit_settings()
240
287
  if row_type == "toggle":
241
288
  toggle_row(current_row)
242
289
  elif row_type == "choice":
243
290
  cycle_choice(current_row, 1)
244
- if event.key == pygame.K_LEFT:
291
+ if event.key == pygame.K_LEFT and row_type != "action":
245
292
  if row_type == "toggle":
246
293
  set_easy_value(current_row, True)
247
294
  elif row_type == "choice":
248
295
  cycle_choice(current_row, -1)
249
- if event.key == pygame.K_RIGHT:
296
+ if event.key == pygame.K_RIGHT and row_type != "action":
250
297
  if row_type == "toggle":
251
298
  set_easy_value(current_row, False)
252
299
  elif row_type == "choice":
@@ -254,6 +301,66 @@ def settings_screen(
254
301
  if event.key == pygame.K_r:
255
302
  working = copy.deepcopy(DEFAULT_CONFIG)
256
303
  set_language(working.get("language"))
304
+ if event.type == pygame.JOYBUTTONDOWN or (
305
+ CONTROLLER_BUTTON_DOWN is not None
306
+ and event.type == CONTROLLER_BUTTON_DOWN
307
+ ):
308
+ current_row = rows[selected]
309
+ row_type = current_row.get("type", "toggle")
310
+ if is_confirm_event(event):
311
+ if row_type == "action":
312
+ return _exit_settings()
313
+ if row_type == "toggle":
314
+ toggle_row(current_row)
315
+ elif row_type == "choice":
316
+ cycle_choice(current_row, 1)
317
+ if CONTROLLER_BUTTON_DOWN is not None and event.type == CONTROLLER_BUTTON_DOWN:
318
+ if (
319
+ CONTROLLER_BUTTON_DPAD_UP is not None
320
+ and event.button == CONTROLLER_BUTTON_DPAD_UP
321
+ ):
322
+ selected = (selected - 1) % row_count
323
+ if (
324
+ CONTROLLER_BUTTON_DPAD_DOWN is not None
325
+ and event.button == CONTROLLER_BUTTON_DPAD_DOWN
326
+ ):
327
+ selected = (selected + 1) % row_count
328
+ if (
329
+ CONTROLLER_BUTTON_DPAD_LEFT is not None
330
+ and event.button == CONTROLLER_BUTTON_DPAD_LEFT
331
+ and row_type != "action"
332
+ ):
333
+ if row_type == "toggle":
334
+ set_easy_value(current_row, True)
335
+ elif row_type == "choice":
336
+ cycle_choice(current_row, -1)
337
+ if (
338
+ CONTROLLER_BUTTON_DPAD_RIGHT is not None
339
+ and event.button == CONTROLLER_BUTTON_DPAD_RIGHT
340
+ and row_type != "action"
341
+ ):
342
+ if row_type == "toggle":
343
+ set_easy_value(current_row, False)
344
+ elif row_type == "choice":
345
+ cycle_choice(current_row, 1)
346
+ if event.type == pygame.JOYHATMOTION:
347
+ current_row = rows[selected]
348
+ row_type = current_row.get("type", "toggle")
349
+ hat_x, hat_y = event.value
350
+ if hat_y == 1:
351
+ selected = (selected - 1) % row_count
352
+ elif hat_y == -1:
353
+ selected = (selected + 1) % row_count
354
+ if hat_x == -1 and row_type != "action":
355
+ if row_type == "toggle":
356
+ set_easy_value(current_row, True)
357
+ elif row_type == "choice":
358
+ cycle_choice(current_row, -1)
359
+ elif hat_x == 1 and row_type != "action":
360
+ if row_type == "toggle":
361
+ set_easy_value(current_row, False)
362
+ elif row_type == "choice":
363
+ cycle_choice(current_row, 1)
257
364
 
258
365
  current_language = get_language()
259
366
  if current_language != last_language:
@@ -284,8 +391,8 @@ def settings_screen(
284
391
  )
285
392
  highlight_color = (70, 70, 70)
286
393
 
287
- row_height = 22
288
- start_y = 52
394
+ row_height = 20
395
+ start_y = 46
289
396
 
290
397
  segment_width = 30
291
398
  segment_height = 18
@@ -294,7 +401,7 @@ def settings_screen(
294
401
 
295
402
  column_margin = 24
296
403
  column_width = screen_width // 2 - column_margin * 2
297
- section_spacing = 6
404
+ section_spacing = 4
298
405
  row_indent = 12
299
406
  value_padding = 20
300
407
 
@@ -305,7 +412,7 @@ def settings_screen(
305
412
  section["label"], False, LIGHT_GRAY
306
413
  )
307
414
  section_states[section["label"]] = {
308
- "next_y": y_cursor + header_surface.get_height() + 6,
415
+ "next_y": y_cursor + header_surface.get_height() + 4,
309
416
  "header_surface": header_surface,
310
417
  "header_pos": (column_margin, y_cursor),
311
418
  }
@@ -324,9 +431,13 @@ def settings_screen(
324
431
  state = section_states[section_label]
325
432
  col_x = column_margin + row_indent
326
433
  row_width = column_width - row_indent + value_padding
327
- value = _get_value(
328
- row["path"], row.get("easy_value", row.get("choices", [None])[0])
329
- )
434
+ row_type = row.get("type", "toggle")
435
+ value = None
436
+ if row_type != "action":
437
+ value = _get_value(
438
+ row["path"],
439
+ row.get("easy_value", row.get("choices", [None])[0]),
440
+ )
330
441
  row_y_current = state["next_y"]
331
442
  state["next_y"] += row_height
332
443
 
@@ -344,7 +455,7 @@ def settings_screen(
344
455
  )
345
456
  )
346
457
  screen.blit(label_surface, label_rect)
347
- if row.get("type", "toggle") == "choice":
458
+ if row_type == "choice":
348
459
  display_fn = row.get("get_display")
349
460
  display_text = (
350
461
  display_fn(value)
@@ -359,7 +470,7 @@ def settings_screen(
359
470
  )
360
471
  )
361
472
  screen.blit(value_surface, value_rect)
362
- else:
473
+ elif row_type == "toggle":
363
474
  slider_y = row_y_current + (row_height - segment_height) // 2 - 2
364
475
  slider_x = col_x + row_width - segment_total_width
365
476
  left_rect = pygame.Rect(