Implement game controller support and add keybindings for gamepad input

This commit is contained in:
2026-05-07 17:50:07 +02:00
parent 9421d8d47c
commit 486fea38e7
4 changed files with 367 additions and 45 deletions
+27
View File
@@ -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
+245 -34
View File
@@ -1,44 +1,255 @@
# This file contains the Controls class, which is responsible for handling user input. # 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 json
import os
from pathlib import Path
import yaml
from runtime_paths import resolve_bundle_path from runtime_paths import resolve_bundle_path
bindings = {}
json_bindings_path = resolve_bundle_path("conf/keybindings.json") DEFAULT_KEYBINDINGS_PROFILE = "pc"
yaml_bindings_path = resolve_bundle_path("conf/keybindings.yaml") KEYBINDINGS_FILE_ENV = "MICE_KEYBINDINGS_FILE"
if os.path.exists(json_bindings_path): KEYBINDINGS_PROFILE_ENV = "MICE_KEYBINDINGS_PROFILE"
with open(json_bindings_path, "r") as f:
bindings = json.load(f)
else: def _normalize_profile_name(profile_name):
import yaml if not profile_name:
# read yaml config file return None
with open(yaml_bindings_path, "r") as f: return profile_name.strip().lower().replace("-", "_")
bindings = yaml.safe_load(f)
class KeyBindings: def _read_text_hint(path_like):
def trigger(self, action): path = Path(path_like)
#print(f"Triggering action: {action}") if not path.exists():
# Check if the action is in the bindings return ""
if action in bindings[f"keybinding_{self.game_status}"]:
value = bindings[f"keybinding_{self.game_status}"][action] try:
# Call the corresponding method value = path.read_bytes().replace(b"\x00", b" ").decode("utf-8", errors="ignore")
if value: except OSError:
#print(f"Calling method: {value}") return ""
if "|" in value:
method_name, *args = value.split("|") return " ".join(value.split())
method = getattr(self, method_name)
method(*args)
else: def _load_bindings_from_file(path):
getattr(self, value)() with path.open("r", encoding="utf-8") as handle:
#else: if path.suffix.lower() == ".json":
#print(f"Action {action} not found in keybindings for {self.game_status}") 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 return
seen_profiles.add(profile)
#print(f"Action {action} not found in keybindings for {self.game_status}") 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 return None
def spawn_new_bomb(self): def spawn_new_bomb(self):
+94 -11
View File
@@ -12,6 +12,36 @@ from PIL import Image
from runtime_paths import resolve_bundle_path 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: class GameWindow:
def __init__(self, width, height, cell_size, title="Default", key_callback=None): def __init__(self, width, height, cell_size, title="Default", key_callback=None):
# Display configuration # Display configuration
@@ -76,6 +106,10 @@ class GameWindow:
self.trigger = key_callback self.trigger = key_callback
self.button_cursor = [0, 0] self.button_cursor = [0, 0]
self.buttons = {} self.buttons = {}
self.joystick = None
self.game_controller = None
self.input_device_kind = "keyboard"
self.input_device_name = None
# Audio system initialization # Audio system initialization
self._init_audio_system() self._init_audio_system()
@@ -412,9 +446,43 @@ class GameWindow:
# ====================== # ======================
def load_joystick(self): def load_joystick(self):
"""Initialize joystick support""" """Initialize joystick and game controller support."""
sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK) sdl2.SDL_Init(sdl2.SDL_INIT_JOYSTICK | sdl2.SDL_INIT_GAMECONTROLLER)
sdl2.SDL_JoystickOpen(0) 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 # MAIN GAME LOOP
@@ -456,15 +524,24 @@ class GameWindow:
elif event.type == sdl2.SDL_MOUSEMOTION: elif event.type == sdl2.SDL_MOUSEMOTION:
self.trigger(f"mousemove_{event.motion.x}, {event.motion.y}") self.trigger(f"mousemove_{event.motion.x}, {event.motion.y}")
elif event.type == sdl2.SDL_JOYBUTTONDOWN: elif event.type == sdl2.SDL_JOYBUTTONDOWN:
key = event.jbutton.button if self.game_controller is None:
self.trigger(f"joybuttondown_{key}") key = event.jbutton.button
self.trigger(f"joybuttondown_{key}")
elif event.type == sdl2.SDL_JOYBUTTONUP: elif event.type == sdl2.SDL_JOYBUTTONUP:
key = event.jbutton.button if self.game_controller is None:
self.trigger(f"joybuttonup_{key}") key = event.jbutton.button
self.trigger(f"joybuttonup_{key}")
elif event.type == sdl2.SDL_JOYHATMOTION: elif event.type == sdl2.SDL_JOYHATMOTION:
hat = event.jhat.hat if self.game_controller is None:
value = event.jhat.value hat = event.jhat.hat
self.trigger(f"joyhatmotion_{hat}_{value}") 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: if event.type == sdl2.SDL_QUIT:
self.running = False self.running = False
return 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 skipped = True
if skipped: if skipped:
@@ -645,6 +722,12 @@ class GameWindow:
def close(self): def close(self):
"""Close the game window and cleanup""" """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 self.running = False
sdl2.ext.quit() sdl2.ext.quit()
+1
View File
@@ -48,6 +48,7 @@ class MiceMaze(
# Apply profile settings # Apply profile settings
if hasattr(self.render_engine, 'set_volume'): if hasattr(self.render_engine, 'set_volume'):
self.render_engine.set_volume(sound_volume) self.render_engine.set_volume(sound_volume)
self.initialize_keybindings()
self.load_assets() self.load_assets()
self.render_engine.window.show() self.render_engine.window.show()