diff --git a/config/accessibility.yaml b/config/accessibility.yaml new file mode 100644 index 0000000..7102a93 --- /dev/null +++ b/config/accessibility.yaml @@ -0,0 +1,7 @@ +font_scale: 1.0 +contrast_mode: default +subtitles_enabled: true +captions_enabled: false +motion_reduction: false +input_delay_buffer: 0.0 +ui_theme: default diff --git a/engine/accessibility_manager.py b/engine/accessibility_manager.py new file mode 100644 index 0000000..71adda8 --- /dev/null +++ b/engine/accessibility_manager.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional +import typing +import os +import json + +try: # pragma: no cover - allow tests without PyYAML + import yaml # type: ignore +except Exception: # pragma: no cover - fallback when PyYAML missing + yaml = None + +try: # pragma: no cover - allow running tests without pygame installed + import pygame # type: ignore +except Exception: # pragma: no cover - allow running tests without pygame + pygame = None + +if typing.TYPE_CHECKING: # pragma: no cover - only for type hints + from .ui_overlay import UIOverlay + + +@dataclass +class AccessibilityManager: + """Manage accessibility preferences and apply them across the UI.""" + + font_scale: float = 1.0 + contrast_mode: str = "default" + subtitles_enabled: bool = True + captions_enabled: bool = False + motion_reduction: bool = False + input_delay_buffer: float = 0.0 + ui_theme: str = "default" + save_file: str = "config/accessibility.yaml" + ui_overlay: Optional["UIOverlay"] = None + + def __post_init__(self) -> None: + if os.path.exists(self.save_file): + self.load_settings(self.save_file) + else: + self.apply_ui_settings() + + # ------------------------------------------------------------------ + # Persistence helpers + # ------------------------------------------------------------------ + def load_settings(self, path: str) -> None: + """Load accessibility options from ``path``.""" + if not os.path.exists(path): + return + with open(path, "r", encoding="utf-8") as fh: + if path.endswith(".json"): + data = json.load(fh) + else: + if yaml: + data = yaml.safe_load(fh) or {} + else: # pragma: no cover - fallback when PyYAML missing + data = json.load(fh) + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + self.apply_ui_settings() + + def save_settings(self, path: Optional[str] = None) -> None: + """Persist current settings to ``path`` or ``save_file``.""" + path = path or self.save_file + data = { + "font_scale": self.font_scale, + "contrast_mode": self.contrast_mode, + "subtitles_enabled": self.subtitles_enabled, + "captions_enabled": self.captions_enabled, + "motion_reduction": self.motion_reduction, + "input_delay_buffer": self.input_delay_buffer, + "ui_theme": self.ui_theme, + } + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + if path.endswith(".json"): + json.dump(data, fh, indent=2, ensure_ascii=False) + else: + if yaml: + yaml.safe_dump(data, fh) + else: # pragma: no cover - fallback when PyYAML missing + json.dump(data, fh, indent=2, ensure_ascii=False) + + # ------------------------------------------------------------------ + # Application helpers + # ------------------------------------------------------------------ + def apply_ui_settings(self) -> None: + """Apply visual settings to the active :class:`UIOverlay`.""" + if not self.ui_overlay or not pygame: + return + base_size = self.ui_overlay.theme.get("font_size", 18) + scaled_size = int(base_size * self.font_scale) + if scaled_size != self.ui_overlay.theme.get("font_size"): + self.ui_overlay.theme["font_size"] = scaled_size + self.ui_overlay.font = pygame.font.Font(None, scaled_size) + + if self.contrast_mode == "high_contrast": + self.ui_overlay.theme["font_color"] = (0, 0, 0) + self.ui_overlay.theme["box_bg_color"] = (255, 255, 255, 230) + elif self.contrast_mode == "night": + self.ui_overlay.theme["font_color"] = (200, 200, 200) + self.ui_overlay.theme["box_bg_color"] = (20, 20, 20, 230) + else: + from .ui_overlay import UIOverlay + + self.ui_overlay.theme["font_color"] = UIOverlay.DEFAULT_THEME["font_color"] + self.ui_overlay.theme["box_bg_color"] = UIOverlay.DEFAULT_THEME["box_bg_color"] + + # ------------------------------------------------------------------ + # Options API + # ------------------------------------------------------------------ + def set_option(self, key: str, value: Any) -> None: + """Set a single option and apply changes.""" + if hasattr(self, key): + setattr(self, key, value) + if key != "save_file" and key != "ui_overlay": + self.apply_ui_settings() + + def get_option(self, key: str) -> Any: + """Retrieve the value of ``key`` if available.""" + return getattr(self, key, None) diff --git a/ui/accessibility_menu.py b/ui/accessibility_menu.py new file mode 100644 index 0000000..62731d1 --- /dev/null +++ b/ui/accessibility_menu.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +try: # pragma: no cover - allow running tests without pygame + import pygame # type: ignore +except Exception: # pragma: no cover - allow running tests without pygame + pygame = None + +from engine.accessibility_manager import AccessibilityManager + + +@dataclass +class MenuEntry: + label: str + key: str + values: List + index: int = 0 + + +class AccessibilityMenu: + """Simple in-game menu to tweak :class:`AccessibilityManager` options.""" + + def __init__(self, manager: AccessibilityManager, font_path: Optional[str] = None) -> None: + self.manager = manager + self.visible = False + self.entries: List[MenuEntry] = [ + MenuEntry("Text Size", "font_scale", [0.8, 1.0, 1.3, 1.6]), + MenuEntry("Contrast", "contrast_mode", ["default", "high_contrast", "night"]), + MenuEntry("Subtitles", "subtitles_enabled", [True, False]), + MenuEntry("Captions", "captions_enabled", [True, False]), + MenuEntry("Reduce Motion", "motion_reduction", [True, False]), + MenuEntry("Input Delay", "input_delay_buffer", [0.0, 0.1, 0.2, 0.5]), + ] + for entry in self.entries: + current = self.manager.get_option(entry.key) + if current in entry.values: + entry.index = entry.values.index(current) + self.selected = 0 + if pygame: + self.font = pygame.font.Font(font_path, 24) + else: + self.font = None + + # ------------------------------------------------------------------ + # Navigation helpers + # ------------------------------------------------------------------ + def navigate(self, direction: int) -> None: + self.selected = (self.selected + direction) % len(self.entries) + + def adjust(self, delta: int) -> None: + entry = self.entries[self.selected] + entry.index = (entry.index + delta) % len(entry.values) + self.manager.set_option(entry.key, entry.values[entry.index]) + + # ------------------------------------------------------------------ + # Input handling + # ------------------------------------------------------------------ + def handle_input(self, event: "pygame.event.Event") -> None: # pragma: no cover - UI only + if not pygame: + return + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_UP: + self.navigate(-1) + elif event.key == pygame.K_DOWN: + self.navigate(1) + elif event.key == pygame.K_LEFT: + self.adjust(-1) + elif event.key == pygame.K_RIGHT: + self.adjust(1) + elif event.key in (pygame.K_ESCAPE, pygame.K_RETURN): + self.visible = False + self.manager.save_settings() + + # ------------------------------------------------------------------ + # Rendering (minimal placeholder) + # ------------------------------------------------------------------ + def render(self, surface: "pygame.Surface") -> None: # pragma: no cover - UI only + if not pygame or not self.font or not self.visible: + return + surface.fill((0, 0, 0)) + for idx, entry in enumerate(self.entries): + prefix = "-> " if idx == self.selected else " " + value = entry.values[entry.index] + label = f"{prefix}{entry.label}: {value}" + color = (255, 255, 0) if idx == self.selected else (255, 255, 255) + surf = self.font.render(label, True, color) + surface.blit(surf, (40, 40 + idx * 30))