From 486fea38e751a6b5b17e494acc3c230196f79f41 Mon Sep 17 00:00:00 2001 From: John Doe Date: Thu, 7 May 2026 17:50:07 +0200 Subject: [PATCH] Implement game controller support and add keybindings for gamepad input --- conf/keybindings_gamepad.yaml | 27 ++++ engine/controls.py | 279 +++++++++++++++++++++++++++++----- engine/sdl2.py | 105 +++++++++++-- rats.py | 1 + 4 files changed, 367 insertions(+), 45 deletions(-) create mode 100644 conf/keybindings_gamepad.yaml diff --git a/conf/keybindings_gamepad.yaml b/conf/keybindings_gamepad.yaml new file mode 100644 index 0000000..a009eca --- /dev/null +++ b/conf/keybindings_gamepad.yaml @@ -0,0 +1,27 @@ +keybinding_game: + controllerbuttondown_a: spawn_rat + controllerbuttondown_dpad_up: start_scrolling|Up + controllerbuttondown_dpad_down: start_scrolling|Down + controllerbuttondown_dpad_left: start_scrolling|Left + controllerbuttondown_dpad_right: start_scrolling|Right + controllerbuttonup_dpad_up: stop_scrolling + controllerbuttonup_dpad_down: stop_scrolling + controllerbuttonup_dpad_left: stop_scrolling + controllerbuttonup_dpad_right: stop_scrolling + controllerbuttondown_x: spawn_new_bomb + controllerbuttondown_y: spawn_new_nuclear_bomb + controllerbuttondown_leftshoulder: spawn_new_mine + controllerbuttondown_rightshoulder: spawn_gas + controllerbuttondown_start: toggle_pause + +keybinding_start_menu: + controllerbuttondown_a: reset_game + controllerbuttondown_start: reset_game + controllerbuttondown_b: quit_game + controllerbuttondown_back: quit_game + +keybinding_paused: + controllerbuttondown_a: reset_game + controllerbuttondown_start: toggle_pause + controllerbuttondown_b: quit_game + controllerbuttondown_back: quit_game \ No newline at end of file diff --git a/engine/controls.py b/engine/controls.py index 49da1c9..386520c 100644 --- a/engine/controls.py +++ b/engine/controls.py @@ -1,44 +1,255 @@ # This file contains the Controls class, which is responsible for handling user input. -# The key_pressed method is called when a key is pressed, and it contains the logic for handling different key presses. -import random -import os import json +import os +from pathlib import Path + +import yaml from runtime_paths import resolve_bundle_path -bindings = {} -json_bindings_path = resolve_bundle_path("conf/keybindings.json") -yaml_bindings_path = resolve_bundle_path("conf/keybindings.yaml") -if os.path.exists(json_bindings_path): - with open(json_bindings_path, "r") as f: - bindings = json.load(f) -else: - import yaml - # read yaml config file - with open(yaml_bindings_path, "r") as f: - bindings = yaml.safe_load(f) - -class KeyBindings: - def trigger(self, action): - #print(f"Triggering action: {action}") - # Check if the action is in the bindings - if action in bindings[f"keybinding_{self.game_status}"]: - value = bindings[f"keybinding_{self.game_status}"][action] - # Call the corresponding method - if value: - #print(f"Calling method: {value}") - if "|" in value: - method_name, *args = value.split("|") - method = getattr(self, method_name) - method(*args) - else: - getattr(self, value)() - #else: - #print(f"Action {action} not found in keybindings for {self.game_status}") + +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(): + 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 "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 - - #print(f"Action {action} not found in keybindings for {self.game_status}") + 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 initialize_keybindings(self): + preferred_profile = None + preferred_file = None + + if hasattr(self, "profile_integration") and self.profile_integration: + preferred_profile = self.profile_integration.get_setting("keybindings_profile") + preferred_file = self.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, "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] + method = getattr(self, method_name, None) + if callable(method): + validated[section_name][action] = value + continue + + invalid_bindings += 1 + print( + f"[input] ignoring binding {section_name}.{action} -> {value}: " + f"missing method {method_name}" + ) + + if invalid_bindings: + print(f"[input] discarded {invalid_bindings} invalid binding(s) from {source_path.name}") + + return validated + + def trigger(self, action): + if not hasattr(self, "bindings"): + self.initialize_keybindings() + + value = self.bindings.get(f"keybinding_{self.game_status}", {}).get(action) + if not value: + return None + + if "|" in value: + method_name, *args = value.split("|") + method = getattr(self, method_name, None) + if callable(method): + method(*args) + return None + + method = getattr(self, value, None) + if callable(method): + method() return None def spawn_new_bomb(self): diff --git a/engine/sdl2.py b/engine/sdl2.py index a8f326f..5208c76 100644 --- a/engine/sdl2.py +++ b/engine/sdl2.py @@ -12,6 +12,36 @@ from PIL import Image from runtime_paths import resolve_bundle_path +CONTROLLER_BUTTON_NAMES = { + sdl2.SDL_CONTROLLER_BUTTON_A: "a", + sdl2.SDL_CONTROLLER_BUTTON_B: "b", + sdl2.SDL_CONTROLLER_BUTTON_X: "x", + sdl2.SDL_CONTROLLER_BUTTON_Y: "y", + sdl2.SDL_CONTROLLER_BUTTON_BACK: "back", + sdl2.SDL_CONTROLLER_BUTTON_GUIDE: "guide", + sdl2.SDL_CONTROLLER_BUTTON_START: "start", + sdl2.SDL_CONTROLLER_BUTTON_LEFTSTICK: "leftstick", + sdl2.SDL_CONTROLLER_BUTTON_RIGHTSTICK: "rightstick", + sdl2.SDL_CONTROLLER_BUTTON_LEFTSHOULDER: "leftshoulder", + sdl2.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: "rightshoulder", + sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP: "dpad_up", + sdl2.SDL_CONTROLLER_BUTTON_DPAD_DOWN: "dpad_down", + sdl2.SDL_CONTROLLER_BUTTON_DPAD_LEFT: "dpad_left", + sdl2.SDL_CONTROLLER_BUTTON_DPAD_RIGHT: "dpad_right", +} + + +def _decode_sdl_string(value): + if not value: + return None + if isinstance(value, (bytes, bytearray)): + return bytes(value).decode("utf-8", errors="ignore") + try: + return value.decode("utf-8", errors="ignore") + except AttributeError: + return str(value) + + class GameWindow: def __init__(self, width, height, cell_size, title="Default", key_callback=None): # Display configuration @@ -76,6 +106,10 @@ class GameWindow: self.trigger = key_callback self.button_cursor = [0, 0] self.buttons = {} + self.joystick = None + self.game_controller = None + self.input_device_kind = "keyboard" + self.input_device_name = None # Audio system initialization self._init_audio_system() @@ -412,9 +446,43 @@ class GameWindow: # ====================== def load_joystick(self): - """Initialize joystick support""" - sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK) - sdl2.SDL_JoystickOpen(0) + """Initialize joystick and game controller support.""" + sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK | sdl2.SDL_INIT_GAMECONTROLLER) + sdl2.SDL_JoystickEventState(sdl2.SDL_ENABLE) + if hasattr(sdl2, "SDL_GameControllerEventState"): + sdl2.SDL_GameControllerEventState(sdl2.SDL_ENABLE) + + num_joysticks = sdl2.SDL_NumJoysticks() + if num_joysticks < 1: + print("[input] no joystick detected") + return + + for device_index in range(num_joysticks): + if hasattr(sdl2, "SDL_IsGameController") and sdl2.SDL_IsGameController(device_index): + controller = sdl2.SDL_GameControllerOpen(device_index) + if controller: + self.game_controller = controller + self.input_device_kind = "gamecontroller" + self.input_device_name = _decode_sdl_string( + sdl2.SDL_GameControllerName(controller) + ) + print(f"[input] game controller detected: {self.input_device_name}") + return + + self.joystick = sdl2.SDL_JoystickOpen(0) + if self.joystick: + self.input_device_kind = "joystick" + self.input_device_name = _decode_sdl_string(sdl2.SDL_JoystickName(self.joystick)) + print(f"[input] raw joystick detected: {self.input_device_name}") + + def get_input_device_summary(self): + return { + "kind": self.input_device_kind, + "name": self.input_device_name, + } + + def _controller_button_name(self, button): + return CONTROLLER_BUTTON_NAMES.get(button, str(button)) # ====================== # MAIN GAME LOOP @@ -456,15 +524,24 @@ class GameWindow: elif event.type == sdl2.SDL_MOUSEMOTION: self.trigger(f"mousemove_{event.motion.x}, {event.motion.y}") elif event.type == sdl2.SDL_JOYBUTTONDOWN: - key = event.jbutton.button - self.trigger(f"joybuttondown_{key}") + if self.game_controller is None: + key = event.jbutton.button + self.trigger(f"joybuttondown_{key}") elif event.type == sdl2.SDL_JOYBUTTONUP: - key = event.jbutton.button - self.trigger(f"joybuttonup_{key}") + if self.game_controller is None: + key = event.jbutton.button + self.trigger(f"joybuttonup_{key}") elif event.type == sdl2.SDL_JOYHATMOTION: - hat = event.jhat.hat - value = event.jhat.value - self.trigger(f"joyhatmotion_{hat}_{value}") + if self.game_controller is None: + hat = event.jhat.hat + value = event.jhat.value + self.trigger(f"joyhatmotion_{hat}_{value}") + elif hasattr(sdl2, "SDL_CONTROLLERBUTTONDOWN") and event.type == sdl2.SDL_CONTROLLERBUTTONDOWN: + button_name = self._controller_button_name(event.cbutton.button) + self.trigger(f"controllerbuttondown_{button_name}") + elif hasattr(sdl2, "SDL_CONTROLLERBUTTONUP") and event.type == sdl2.SDL_CONTROLLERBUTTONUP: + button_name = self._controller_button_name(event.cbutton.button) + self.trigger(f"controllerbuttonup_{button_name}") @@ -537,7 +614,7 @@ class GameWindow: if event.type == sdl2.SDL_QUIT: self.running = False return - elif event.type in (sdl2.SDL_KEYDOWN, sdl2.SDL_JOYBUTTONDOWN): + elif event.type in (sdl2.SDL_KEYDOWN, sdl2.SDL_JOYBUTTONDOWN, sdl2.SDL_CONTROLLERBUTTONDOWN): skipped = True if skipped: @@ -645,6 +722,12 @@ class GameWindow: def close(self): """Close the game window and cleanup""" + if self.game_controller: + sdl2.SDL_GameControllerClose(self.game_controller) + self.game_controller = None + if self.joystick: + sdl2.SDL_JoystickClose(self.joystick) + self.joystick = None self.running = False sdl2.ext.quit() diff --git a/rats.py b/rats.py index 30c5e65..3235186 100644 --- a/rats.py +++ b/rats.py @@ -48,6 +48,7 @@ class MiceMaze( # Apply profile settings if hasattr(self.render_engine, 'set_volume'): self.render_engine.set_volume(sound_volume) + self.initialize_keybindings() self.load_assets() self.render_engine.window.show()