diff --git a/pygame/controller_debug.pygame b/pygame/controller_debug.pygame new file mode 100644 index 0000000..fa78905 --- /dev/null +++ b/pygame/controller_debug.pygame @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +import os +import sys +import time +import traceback +from typing import Any, Dict, Tuple, List + +try: + import pygame # type: ignore +except Exception as e: + print("Pygame is required. Install with: pip install pygame") + raise + + +PROMPTS = [ + # Face buttons + "SOUTH_BUTTON", # A on Xbox + "EAST_BUTTON", # B on Xbox + "WEST_BUTTON", # X on Xbox + "NORTH_BUTTON", # Y on Xbox + # Meta + "START", + "SELECT", + # D-Pad + "DPAD_UP", + "DPAD_DOWN", + "DPAD_LEFT", + "DPAD_RIGHT", + # Bumpers + "LEFT_BUMPER", + "RIGHT_BUMPER", + # Triggers + "LEFT_TRIGGER", + "RIGHT_TRIGGER", + # Left stick directions + "JOYSTICK_LEFT_UP", + "JOYSTICK_LEFT_DOWN", + "JOYSTICK_LEFT_LEFT", + "JOYSTICK_LEFT_RIGHT", + # Right stick directions + "JOYSTICK_RIGHT_UP", + "JOYSTICK_RIGHT_DOWN", + "JOYSTICK_RIGHT_LEFT", + "JOYSTICK_RIGHT_RIGHT", +] + + +# --- Minimal on-screen console (Pygame window) --- +SURFACE = None # type: ignore +FONT = None # type: ignore +LOG_LINES: List[str] = [] +MAX_LOG = 300 + + +def init_screen(width: int = 900, height: int = 600) -> None: + global SURFACE, FONT + try: + pygame.display.init() + SURFACE = pygame.display.set_mode((width, height)) + pygame.display.set_caption("Controller Tester") + pygame.font.init() + FONT = pygame.font.SysFont("Consolas", 20) or pygame.font.Font(None, 20) + except Exception: + # If display init fails, stay headless but continue + SURFACE = None + FONT = None + + +def log(msg: str) -> None: + # Print to real console and on-screen log + try: + print(msg) + except Exception: + pass + LOG_LINES.append(str(msg)) + if len(LOG_LINES) > MAX_LOG: + del LOG_LINES[: len(LOG_LINES) - MAX_LOG] + draw_log() + + +def draw_log() -> None: + if SURFACE is None or FONT is None: + return + try: + SURFACE.fill((12, 12, 12)) + margin = 12 + line_h = FONT.get_height() + 4 + # Show the last N lines that fit on screen + max_lines = (SURFACE.get_height() - margin * 2) // line_h + to_draw = LOG_LINES[-max_lines:] + y = margin + for line in to_draw: + surf = FONT.render(line, True, (220, 220, 220)) + SURFACE.blit(surf, (margin, y)) + y += line_h + pygame.display.flip() + except Exception: + pass + + +def init_joystick() -> pygame.joystick.Joystick: + pygame.init() + pygame.joystick.init() + if pygame.joystick.get_count() == 0: + log("No joystick detected. Connect a controller and try again.") + sys.exit(1) + js = pygame.joystick.Joystick(0) + js.init() + name = js.get_name() + log(f"Using joystick 0: {name}") + return js + + +def wait_for_stable(js: pygame.joystick.Joystick, settle_ms: int = 250, deadband: float = 0.05, timeout_ms: int = 2000) -> bool: + """Wait until axes stop moving (change < deadband) continuously for settle_ms. + + Unlike a traditional neutral check, this doesn't assume axes center at 0. + Hats are required to be (0,0) to avoid capturing D-Pad releases. + Returns True if stability achieved, False on timeout. + """ + start = pygame.time.get_ticks() + last = [js.get_axis(i) for i in range(js.get_numaxes())] + stable_since = None + while True: + # Handle window close only (avoid quitting on keyboard here) + for event in pygame.event.get(): + if event.type == pygame.QUIT: + log("Window closed. Exiting.") + sys.exit(0) + moved = False + for i in range(js.get_numaxes()): + cur = js.get_axis(i) + if abs(cur - last[i]) > deadband: + moved = True + last[i] = cur + hats_ok = all(js.get_hat(i) == (0, 0) for i in range(js.get_numhats())) + if not moved and hats_ok: + if stable_since is None: + stable_since = pygame.time.get_ticks() + elif pygame.time.get_ticks() - stable_since >= settle_ms: + return True + else: + stable_since = None + if pygame.time.get_ticks() - start > timeout_ms: + return False + draw_log() + pygame.time.wait(10) + + +def wait_for_event(js: pygame.joystick.Joystick, logical_name: str, axis_threshold: float = 0.6) -> Tuple[str, Any]: + """Wait for a joystick event for the given logical control. + + Returns a tuple of (kind, data): + - ("button", button_index) + - ("hat", (x, y)) where x,y in {-1,0,1} + - ("axis", {"axis": index, "direction": -1|1}) + """ + # Ensure prior motion has settled to avoid capturing a release + wait_for_stable(js) + log("") + log(f"Press {logical_name} (ESC to skip, close window to quit)…") + # Flush old events + pygame.event.clear() + while True: + for event in pygame.event.get(): + # Keyboard helpers + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + log(f"Skipped {logical_name}") + return ("skipped", None) + # No keyboard quit here to avoid accidental exits when using controllers + if event.type == pygame.QUIT: + log("Window closed. Exiting.") + sys.exit(0) + + # Buttons + if event.type == pygame.JOYBUTTONDOWN: + log(f"Captured {logical_name}: BUTTON {event.button}") + return ("button", event.button) + + # D-Pad (HAT) + if event.type == pygame.JOYHATMOTION: + val = event.value # (x, y) + if val != (0, 0): + log(f"Captured {logical_name}: HAT {val}") + return ("hat", val) + + # Axes (sticks, triggers) + if event.type == pygame.JOYAXISMOTION: + axis = event.axis + value = float(event.value) + if abs(value) >= axis_threshold: + direction = 1 if value > 0 else -1 + log(f"Captured {logical_name}: AXIS {axis} dir {direction} (raw {value:.2f})") + return ("axis", {"axis": axis, "direction": direction, "raw": value}) + + draw_log() + time.sleep(0.005) + + +def write_log(path: str, mapping: Dict[str, Tuple[str, Any]], device_name: str) -> None: + lines = [] + lines.append("# Controller mapping log\n") + lines.append(f"# Device: {device_name}\n\n") + for name, (kind, data) in mapping.items(): + if kind == "button": + lines.append(f"{name} = BUTTON {data}\n") + elif kind == "hat": + lines.append(f"{name} = HAT {data}\n") + elif kind == "axis": + ax = data.get("axis") + direction = data.get("direction") + lines.append(f"{name} = AXIS {ax} dir {direction}\n") + elif kind == "skipped": + lines.append(f"{name} = SKIPPED\n") + else: + lines.append(f"{name} = UNKNOWN {data}\n") + + with open(path, "w", encoding="utf-8") as f: + f.writelines(lines) + log("") + log(f"Saved mapping to: {path}") + + +def main() -> None: + init_screen() + js = init_joystick() + # Print device basics + try: + log(f"Buttons: {js.get_numbuttons()} | Axes: {js.get_numaxes()} | Hats: {js.get_numhats()}") + except Exception: + pass + + mapping: Dict[str, Tuple[str, Any]] = {} + for logical in PROMPTS: + kind, data = wait_for_event(js, logical) + mapping[logical] = (kind, data) + # Short, consistent debounce for all inputs + pygame.event.clear() + pygame.time.wait(150) + + log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "controller_mapping.log") + write_log(log_path, mapping, js.get_name()) + log("Done. Press Q or close the window to exit.") + + +if __name__ == "__main__": + try: + main() + except SystemExit: + # Allow intentional exits + pass + except Exception: + # Show traceback on screen and wait for window close + tb = traceback.format_exc() + try: + log("") + log("An error occurred:") + for line in tb.splitlines(): + log(line) + # Idle until window is closed + while True: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + raise SystemExit(1) + draw_log() + pygame.time.wait(50) + except Exception: + pass + finally: + try: + pygame.joystick.quit() + pygame.quit() + except Exception: + pass