Implement game controller support and add keybindings for gamepad input
This commit is contained in:
@@ -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
|
||||
+243
-32
@@ -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)
|
||||
|
||||
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
|
||||
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 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}")
|
||||
return
|
||||
def initialize_keybindings(self):
|
||||
preferred_profile = None
|
||||
preferred_file = None
|
||||
|
||||
#print(f"Action {action} not found in keybindings for {self.game_status}")
|
||||
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):
|
||||
|
||||
+94
-11
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user