# This file contains the Controls class, which is responsible for handling user input. import configparser import json import os from pathlib import Path import yaml from runtime_paths import resolve_bundle_path DEFAULT_KEYBINDINGS_PROFILE = "pc" KEYBINDINGS_FILE_ENV = "MICE_KEYBINDINGS_FILE" KEYBINDINGS_PROFILE_ENV = "MICE_KEYBINDINGS_PROFILE" def _normalize_profile_name(profile_name): if not profile_name: return None return profile_name.strip().lower().replace("-", "_") def _read_text_hint(path_like): path = Path(path_like) if not path.exists(): return "" try: value = path.read_bytes().replace(b"\x00", b" ").decode("utf-8", errors="ignore") except OSError: return "" return " ".join(value.split()) def _load_bindings_from_file(path): with path.open("r", encoding="utf-8") as handle: if path.suffix.lower() == ".json": data = json.load(handle) elif path.suffix.lower() in {".yaml", ".yml"}: data = yaml.safe_load(handle) or {} else: raise ValueError(f"Unsupported keybindings format: {path.suffix}") if not isinstance(data, dict): raise ValueError(f"Keybindings file must contain a mapping: {path}") return data def _profile_candidates(profile_name): profile = _normalize_profile_name(profile_name) if not profile: return [] conf_dir = resolve_bundle_path("conf") candidates = [ conf_dir / f"keybindings_{profile}.json", conf_dir / f"keybindings_{profile}.yaml", conf_dir / f"keybindings_{profile}.yml", ] if profile == DEFAULT_KEYBINDINGS_PROFILE: candidates.extend([ conf_dir / "keybindings.json", conf_dir / "keybindings.yaml", conf_dir / "keybindings.yml", ]) unique_candidates = [] seen = set() for candidate in candidates: candidate_str = str(candidate) if candidate_str in seen: continue seen.add(candidate_str) unique_candidates.append(candidate) return unique_candidates def _detect_linux_hardware_profile(): muos_config_path = Path("/opt/muos/device/current/config.ini") if muos_config_path.exists(): parser = configparser.ConfigParser() try: parser.read(muos_config_path, encoding="utf-8") board_name = parser.get("board", "name", fallback="").strip().casefold() except (configparser.Error, OSError): board_name = "" if "rg40xx" in board_name: return "rg40xx", f"muos:{board_name}" if "r36s" in board_name: return "r36s", f"muos:{board_name}" hints = [] for path_like in [ "/proc/device-tree/model", "/sys/firmware/devicetree/base/model", "/sys/devices/virtual/dmi/id/sys_vendor", "/sys/devices/virtual/dmi/id/product_name", "/sys/devices/virtual/dmi/id/product_version", "/tmp/sysinfo/model", ]: hint = _read_text_hint(path_like) if hint: hints.append(hint) combined = " ".join(hints).casefold() if not combined: return None, None hardware_profiles = { "r36s": ("r36s",), "rg40xx": ("rg40xx", "anbernic rg40xx", "rg40xx h"), } for profile, tokens in hardware_profiles.items(): if any(token in combined for token in tokens): return profile, "; ".join(hints) return None, None def _detect_runtime_profile(render_engine): if render_engine is None: return None, None device_kind = getattr(render_engine, "input_device_kind", None) device_name = (getattr(render_engine, "input_device_name", "") or "").casefold() if device_kind == "gamecontroller": return "gamepad", device_name or "SDL GameController" if device_kind == "joystick": if "muos-keys" in device_name: return "rg40xx", device_name if "r36s" in device_name: return "r36s", device_name if "rg40xx" in device_name: return "rg40xx", device_name return None, None def resolve_keybindings(preferred_profile=None, preferred_file=None, render_engine=None): explicit_file = preferred_file or os.environ.get(KEYBINDINGS_FILE_ENV) if explicit_file: path = resolve_bundle_path(explicit_file) if path.exists(): explicit_profile = path.stem if explicit_profile.startswith("keybindings_"): explicit_profile = explicit_profile[len("keybindings_"):] return _load_bindings_from_file(path), path, explicit_profile, KEYBINDINGS_FILE_ENV print(f"[input] requested keybindings file not found: {path}") profiles_to_try = [] seen_profiles = set() def add_profile(profile_name, reason): profile = _normalize_profile_name(profile_name) if not profile or profile in seen_profiles: return seen_profiles.add(profile) profiles_to_try.append((profile, reason)) add_profile(os.environ.get(KEYBINDINGS_PROFILE_ENV), KEYBINDINGS_PROFILE_ENV) add_profile(preferred_profile, "profile_setting") hardware_profile, hardware_reason = _detect_linux_hardware_profile() if hardware_profile: add_profile(hardware_profile, f"hardware:{hardware_reason}") runtime_profile, runtime_reason = _detect_runtime_profile(render_engine) if runtime_profile: add_profile(runtime_profile, f"runtime:{runtime_reason}") add_profile(DEFAULT_KEYBINDINGS_PROFILE, "default") errors = [] for profile, reason in profiles_to_try: for candidate in _profile_candidates(profile): if not candidate.exists(): continue try: bindings = _load_bindings_from_file(candidate) return bindings, candidate, profile, reason except (OSError, ValueError, json.JSONDecodeError, yaml.YAMLError) as exc: errors.append(f"{candidate}: {exc}") if errors: raise RuntimeError("Failed to load keybindings:\n" + "\n".join(errors)) raise FileNotFoundError("No keybindings configuration could be resolved") class KeyBindings: def __init__(self, game): self.game = game self.bindings = {} # Explicit action mapping for static-friendly dispatch (Nim-ready) self.action_dispatcher = { "spawn_rat": self.spawn_rat, "spawn_new_bomb": self.spawn_new_bomb, "spawn_new_mine": self.spawn_new_mine, "spawn_new_nuclear_bomb": self.spawn_new_nuclear_bomb, "spawn_new_gas": self.spawn_new_gas, "toggle_audio": self.toggle_audio, "toggle_pause": self.toggle_pause, "toggle_full_screen": self.toggle_full_screen, "quit_game": self.quit_game, "menu_up": self.game.menu_up, "menu_down": self.game.menu_down, "menu_left": self.game.menu_left, "menu_right": self.game.menu_right, "reset_game": self.game.reset_game, "start_scrolling": self.start_scrolling, "stop_scrolling": self.stop_scrolling, } def _binding_sections_for_action(self): game_end_active, game_end_reason = getattr(self.game, "game_end", (False, None)) if game_end_active: if game_end_reason == "level_clear": return ["keybinding_level_clear", "keybinding_paused"] if game_end_reason == "defeat": return ["keybinding_defeat", "keybinding_paused"] if game_end_reason == "run_complete": return ["keybinding_run_complete", "keybinding_paused"] return ["keybinding_paused"] if getattr(self.game, "game_status", None) == "start_menu": if getattr(self.game, "menu_screen", None) == "level_intro": return ["keybinding_level_intro", "keybinding_start_menu"] return ["keybinding_start_menu"] status = getattr(self.game, "game_status", None) if status: return [f"keybinding_{status}"] return [] def initialize_keybindings(self): preferred_profile = None preferred_file = None if hasattr(self.game, "profile_integration") and self.game.profile_integration: preferred_profile = self.game.profile_integration.get_setting("keybindings_profile") preferred_file = self.game.profile_integration.get_setting("keybindings_file") bindings, source_path, profile_name, reason = resolve_keybindings( preferred_profile=preferred_profile, preferred_file=preferred_file, render_engine=getattr(self.game, "render_engine", None), ) self.bindings = self._validate_bindings(bindings, source_path) self.keybindings_profile = profile_name self.keybindings_source = str(source_path) self.keybindings_reason = reason print( f"[input] keybindings profile={profile_name} source={source_path.name} reason={reason}" ) def _validate_bindings(self, bindings, source_path): validated = {} invalid_bindings = 0 for section_name, action_map in bindings.items(): if not isinstance(action_map, dict): print(f"[input] ignoring invalid section {section_name!r} in {source_path}") continue validated[section_name] = {} for action, value in action_map.items(): if not value: continue method_name = value.split("|", 1)[0] if method_name in self.action_dispatcher: validated[section_name][action] = value continue invalid_bindings += 1 print( f"[input] ignoring binding {section_name}.{action} -> {value}: " f"missing method {method_name} in dispatcher" ) if invalid_bindings: print(f"[input] discarded {invalid_bindings} invalid binding(s) from {source_path.name}") return validated def trigger(self, action): if not self.bindings: self.initialize_keybindings() value = None for section_name in self._binding_sections_for_action(): value = self.bindings.get(section_name, {}).get(action) if value: break if not value: return None if "|" in value: method_name, *args = value.split("|") method = self.action_dispatcher.get(method_name) if method: method(*args) return None method = self.action_dispatcher.get(value) if method: method() return None def spawn_rat(self): self.game.unit_manager.spawn_rat() def spawn_new_bomb(self): self.game.unit_manager.spawn_bomb(self.game.pointer) def spawn_new_mine(self): self.game.unit_manager.spawn_mine(self.game.pointer) def spawn_new_nuclear_bomb(self): self.game.unit_manager.spawn_nuclear_bomb(self.game.pointer) def spawn_new_gas(self): self.game.unit_manager.spawn_gas() def toggle_audio(self): self.game.render_engine.audio = not self.game.render_engine.audio self.game.audio = self.game.render_engine.audio if hasattr(self.game, "profile_integration") and self.game.profile_integration: self.game.profile_integration.set_setting("sound_enabled", self.game.audio) if not self.game.render_engine.audio: self.game.render_engine.stop_sound() def toggle_pause(self): if getattr(self.game, "game_end", (False, None))[0]: return if self.game.state_machine.current_state == state_machine.GameState.PLAYING: self.game.state_machine.transition_to(state_machine.GameState.PAUSED) return if self.game.state_machine.current_state == state_machine.GameState.PAUSED: self.game.state_machine.transition_to(state_machine.GameState.PLAYING) return if self.game.state_machine.current_state == state_machine.GameState.START_MENU: self.game.reset_game() def toggle_full_screen(self): self.game.full_screen = not self.game.full_screen self.game.render_engine.full_screen(self.game.full_screen) def quit_game(self): self.game.render_engine.close() def start_scrolling(self, direction): self.game.scrolling_direction = direction if not self.game.scrolling: self.game.scrolling = 1 def stop_scrolling(self): self.game.scrolling = 0 def scroll(self): if self.game.scrolling: if not self.game.scrolling % 5: if self.game.scrolling_direction == "Up": self.game.graphics.scroll_cursor(y=-1) elif self.game.scrolling_direction == "Down": self.game.graphics.scroll_cursor(y=1) elif self.game.scrolling_direction == "Left": self.game.graphics.scroll_cursor(x=-1) elif self.game.scrolling_direction == "Right": self.game.graphics.scroll_cursor(x=1) self.game.scrolling += 1 def axis_scroll(self, x, y): self.game.graphics.scroll_cursor(1 if x > 0 else -1, 1 if y > 0 else -1)