diff --git a/ports/RGSX/scraper.py b/ports/RGSX/scraper.py
index c5ce5fe..5c3d5eb 100644
--- a/ports/RGSX/scraper.py
+++ b/ports/RGSX/scraper.py
@@ -1,5 +1,5 @@
"""
-Module de scraping pour récupérer les métadonnées des jeux depuis TheGamesDB.net
+Module de scraping pour récupérer les métadonnées des jeux depuis TheGamesDB.net API v1
"""
import logging
import requests
@@ -9,138 +9,167 @@ import pygame
logger = logging.getLogger(__name__)
-# Mapping des noms de plateformes vers leurs IDs sur TheGamesDB
-# Les noms correspondent exactement à ceux utilisés dans systems_list.json
+# Clé API publique pour TheGamesDB
+API_KEY = "bdbb4a1ce5f1c12c1bcc119aeb4d4923d3887e22ad336d576e9b9e5da5ecaa3c"
+API_BASE_URL = "https://api.thegamesdb.net/v1"
+
+# Mapping des noms de plateformes vers leurs IDs sur TheGamesDB API
+# Documentation: https://api.thegamesdb.net/#/Platforms
PLATFORM_MAPPING = {
# Noms exacts du systems_list.json
- "3DO Interactive Multiplayer": "25",
- "3DS": "4912",
- "Adventure Vision": "4974",
- "Amiga CD32": "4947",
- "Amiga CDTV": "4947", # Même ID que CD32
- "Amiga OCS ECS": "4911",
- "Apple II": "4942",
- "Apple IIGS": "4942", # Même famille
- "Arcadia 2001": "4963",
- "Archimedes": "4944",
- "Astrocade": "4968",
- "Atari 2600": "22",
- "Atari 5200": "26",
- "Atari 7800": "27",
- "Atari Lynx": "4924",
- "Atari ST": "4937",
- "Atom": "5014",
- "Channel-F": "4928",
- "ColecoVision": "31",
- "Commodore 64": "40",
- "Commodore Plus4": "5007",
- "Commodore VIC-20": "4945",
- "CreatiVision": "5005",
- "Dos (x86)": "1",
- "Dreamcast": "16",
- "Family Computer Disk System": "4936",
- "Final Burn Neo": "23", # Arcade
- "FM-TOWNS": "4932",
- "Gamate": "5004",
- "Game Boy": "4",
- "Game Boy Advance": "5",
- "Game Boy Color": "41",
- "Game Cube": "2",
- "Game Gear": "20",
- "Game Master": "4948", # Mega Duck
- "Game.com": "4940",
- "Jaguar": "28",
- "Macintosh": "37",
- "Master System": "35",
- "Mattel Intellivision": "32",
- "Mega CD": "21",
- "Mega Drive": "36",
- "Mega Duck Cougar Boy": "4948",
- "MSX1": "4929",
- "MSX2+": "4929",
- "Namco System 246 256": "23", # Arcade
- "Naomi": "23", # Arcade
- "Naomi 2": "23", # Arcade
- "Neo-Geo CD": "4956",
- "Neo-Geo Pocket": "4922",
- "Neo-Geo Pocket Color": "4923",
- "Neo-Geo": "24",
- "Nintendo 64": "3",
- "Nintendo 64 Disk Drive": "3",
- "Nintendo DS": "8",
- "Nintendo DSi": "8",
- "Nintendo Entertainment System": "7",
- "Odyssey2": "4927",
- "PC Engine": "34",
- "PC Engine CD": "4955",
- "PC Engine SuperGrafx": "34",
- "PC-9800": "4934",
- "PlayStation": "10",
- "PlayStation 2": "11",
- "PlayStation 3": "12",
- "PlayStation Portable": "13",
- "PlayStation Vita": "39",
- "Pokemon Mini": "4957",
- "PV-1000": "4964",
- "Satellaview": "6", # SNES addon
- "Saturn": "17",
- "ScummVM": "1", # PC
- "Sega 32X": "33",
- "Sega Chihiro": "23", # Arcade
- "Sega Pico": "4958",
- "SG-1000": "4949",
- "Sharp X1": "4977",
- "SuFami Turbo": "6", # SNES addon
- "Super A'Can": "4918", # Pas d'ID exact, utilise Virtual Boy
- "Super Cassette Vision": "4966",
- "Super Nintendo Entertainment System": "6",
- "Supervision": "4959",
- "Switch (1Fichier)": "4971",
- "TI-99": "4953",
- "V.Smile": "4988",
- "Vectrex": "4939",
- "Virtual Boy": "4918",
- "Wii": "9",
- "Wii (Virtual Console)": "9",
- "Wii U": "38",
- "Windows (1Fichier)": "1",
- "WonderSwan": "4925",
- "WonderSwan Color": "4926",
- "Xbox": "14",
- "Xbox 360": "15",
- "ZX Spectrum": "4913",
- "Game and Watch": "4950",
- "Nintendo Famicom Disk System": "4936",
+ "3DO Interactive Multiplayer": 25,
+ "3DS": 4912,
+ "Adventure Vision": 4974,
+ "Amiga CD32": 4947,
+ "Amiga CDTV": 4947,
+ "Amiga OCS ECS": 4911,
+ "Apple II": 4942,
+ "Apple IIGS": 4942,
+ "Arcadia 2001": 4963,
+ "Archimedes": 4944,
+ "Astrocade": 4968,
+ "Atari 2600": 22,
+ "Atari 5200": 26,
+ "Atari 7800": 27,
+ "Atari Lynx": 4924,
+ "Atari ST": 4937,
+ "Atom": 5014,
+ "Channel-F": 4928,
+ "ColecoVision": 31,
+ "Commodore 64": 40,
+ "Commodore Plus4": 5007,
+ "Commodore VIC-20": 4945,
+ "CreatiVision": 5005,
+ "Dos (x86)": 1,
+ "Dreamcast": 16,
+ "Family Computer Disk System": 4936,
+ "Final Burn Neo": 23,
+ "FM-TOWNS": 4932,
+ "Gamate": 5004,
+ "Game Boy": 4,
+ "Game Boy Advance": 5,
+ "Game Boy Color": 41,
+ "Game Cube": 2,
+ "Game Gear": 20,
+ "Game Master": 4948,
+ "Game.com": 4940,
+ "Jaguar": 28,
+ "Macintosh": 37,
+ "Master System": 35,
+ "Mattel Intellivision": 32,
+ "Mega CD": 21,
+ "Mega Drive": 36,
+ "Mega Duck Cougar Boy": 4948,
+ "MSX1": 4929,
+ "MSX2+": 4929,
+ "Namco System 246 256": 23,
+ "Naomi": 23,
+ "Naomi 2": 23,
+ "Neo-Geo CD": 4956,
+ "Neo-Geo Pocket": 4922,
+ "Neo-Geo Pocket Color": 4923,
+ "Neo-Geo": 24,
+ "Nintendo 64": 3,
+ "Nintendo 64 Disk Drive": 3,
+ "Nintendo DS": 8,
+ "Nintendo DSi": 8,
+ "Nintendo Entertainment System": 7,
+ "Odyssey2": 4927,
+ "PC Engine": 34,
+ "PC Engine CD": 4955,
+ "PC Engine SuperGrafx": 34,
+ "PC-9800": 4934,
+ "PlayStation": 10,
+ "PlayStation 2": 11,
+ "PlayStation 3": 12,
+ "PlayStation Portable": 13,
+ "PlayStation Vita": 39,
+ "Pokemon Mini": 4957,
+ "PV-1000": 4964,
+ "Satellaview": 6,
+ "Saturn": 17,
+ "ScummVM": 1,
+ "Sega 32X": 33,
+ "Sega Chihiro": 23,
+ "Sega Pico": 4958,
+ "SG-1000": 4949,
+ "Sharp X1": 4977,
+ "SuFami Turbo": 6,
+ "Super A'Can": 4918,
+ "Super Cassette Vision": 4966,
+ "Super Nintendo Entertainment System": 6,
+ "Supervision": 4959,
+ "Switch (1Fichier)": 4971,
+ "TI-99": 4953,
+ "V.Smile": 4988,
+ "Vectrex": 4939,
+ "Virtual Boy": 4918,
+ "Wii": 9,
+ "Wii (Virtual Console)": 9,
+ "Wii U": 38,
+ "Windows (1Fichier)": 1,
+ "WonderSwan": 4925,
+ "WonderSwan Color": 4926,
+ "Xbox": 14,
+ "Xbox 360": 15,
+ "ZX Spectrum": 4913,
+ "Game and Watch": 4950,
+ "Nintendo Famicom Disk System": 4936,
# Aliases communs (pour compatibilité)
- "3DO": "25",
- "NES": "7",
- "SNES": "6",
- "GBA": "5",
- "GBC": "41",
- "GameCube": "2",
- "N64": "3",
- "NDS": "8",
- "PSX": "10",
- "PS1": "10",
- "PS2": "11",
- "PS3": "12",
- "PSP": "13",
- "PS Vita": "39",
- "Genesis": "18",
- "32X": "33",
- "Game & Watch": "4950",
- "PC-98": "4934",
- "TurboGrafx 16": "34",
- "TurboGrafx CD": "4955",
- "Mega Duck": "4948",
- "Amiga": "4911"
+ "3DO": 25,
+ "NES": 7,
+ "SNES": 6,
+ "GBA": 5,
+ "GBC": 41,
+ "GameCube": 2,
+ "N64": 3,
+ "NDS": 8,
+ "PSX": 10,
+ "PS1": 10,
+ "PS2": 11,
+ "PS3": 12,
+ "PSP": 13,
+ "PS Vita": 39,
+ "Genesis": 18,
+ "32X": 33,
+ "Game & Watch": 4950,
+ "PC-98": 4934,
+ "TurboGrafx 16": 34,
+ "TurboGrafx CD": 4955,
+ "Mega Duck": 4948,
+ "Amiga": 4911
}
+def clean_game_name(game_name):
+ """
+ Nettoie le nom du jeu en supprimant les extensions et tags
+
+ Args:
+ game_name (str): Nom brut du jeu
+
+ Returns:
+ str: Nom nettoyé
+ """
+ clean_name = game_name
+
+ # Supprimer les extensions communes
+ extensions = ['.zip', '.7z', '.rar', '.iso', '.chd', '.cue', '.bin', '.gdi', '.cdi',
+ '.nsp', '.xci', '.wbfs', '.rvz', '.gcz', '.wad', '.3ds', '.cia']
+ for ext in extensions:
+ if clean_name.lower().endswith(ext):
+ clean_name = clean_name[:-len(ext)]
+
+ # Supprimer les tags entre parenthèses et crochets
+ clean_name = re.sub(r'\s*[\(\[].*?[\)\]]', '', clean_name)
+
+ return clean_name.strip()
+
+
def get_game_metadata(game_name, platform_name):
"""
- Récupère les métadonnées complètes d'un jeu depuis TheGamesDB.net
+ Récupère les métadonnées complètes d'un jeu depuis TheGamesDB.net API
Args:
game_name (str): Nom du jeu à rechercher
@@ -150,100 +179,128 @@ def get_game_metadata(game_name, platform_name):
dict: Dictionnaire contenant les métadonnées ou message d'erreur
Keys: image_url, game_page_url, description, genre, release_date, error
"""
- # Nettoyer le nom du jeu
- clean_game_name = game_name
- for ext in ['.zip', '.7z', '.rar', '.iso', '.chd', '.cue', '.bin', '.gdi', '.cdi']:
- if clean_game_name.lower().endswith(ext):
- clean_game_name = clean_game_name[:-len(ext)]
- clean_game_name = re.sub(r'\s*[\(\[].*?[\)\]]', '', clean_game_name)
- clean_game_name = clean_game_name.strip()
-
- logger.info(f"Recherche métadonnées pour: '{clean_game_name}' sur plateforme '{platform_name}'")
+ clean_name = clean_game_name(game_name)
+ logger.info(f"Recherche métadonnées pour: '{clean_name}' sur plateforme '{platform_name}'")
# Obtenir l'ID de la plateforme
platform_id = PLATFORM_MAPPING.get(platform_name)
if not platform_id:
+ logger.warning(f"Plateforme '{platform_name}' non trouvée dans le mapping")
return {"error": f"Plateforme '{platform_name}' non supportée"}
- # Construire l'URL de recherche
- base_url = "https://thegamesdb.net/search.php"
- params = {
- "name": clean_game_name,
- "platform_id[]": platform_id
- }
-
try:
- # Envoyer la requête GET pour la recherche
- logger.debug(f"Recherche sur TheGamesDB: {base_url} avec params={params}")
- response = requests.get(base_url, params=params, timeout=10)
+ # Endpoint: Games/ByGameName
+ # Documentation: https://api.thegamesdb.net/#/Games/GamesbyName
+ url = f"{API_BASE_URL}/Games/ByGameName"
+ params = {
+ "apikey": API_KEY,
+ "name": clean_name,
+ "filter[platform]": platform_id,
+ "fields": "players,publishers,genres,overview,last_updated,rating,platform,coop,youtube,os,processor,ram,hdd,video,sound,alternates",
+ "include": "boxart"
+ }
+
+ logger.debug(f"Requête API: {url} avec name='{clean_name}', platform={platform_id}")
+ response = requests.get(url, params=params, timeout=15)
if response.status_code != 200:
+ logger.error(f"Erreur HTTP {response.status_code}: {response.text}")
return {"error": f"Erreur HTTP {response.status_code}"}
- html_content = response.text
+ data = response.json()
- # Trouver la première carte avec class 'card border-primary'
- card_start = html_content.find('div class="card border-primary"')
- if card_start == -1:
+ # Vérifier si des résultats ont été trouvés
+ if "data" not in data or "games" not in data["data"] or not data["data"]["games"]:
+ logger.warning(f"Aucun résultat trouvé pour '{clean_name}'")
return {"error": "Aucun résultat trouvé"}
- # Extraire l'URL de la page du jeu
- href_match = re.search(r'', html_content[card_start-100:card_start+500])
- game_page_url = None
- if href_match:
- game_page_url = f"https://thegamesdb.net/{href_match.group(1)[2:]}" # Enlever le ./
- logger.info(f"Page du jeu trouvée: {game_page_url}")
+ # Prendre le premier résultat (meilleure correspondance)
+ games = data["data"]["games"]
+ game = games[0]
+ game_id = game.get("id")
- # Extraire l'URL de l'image
- img_start = html_content.find('(\d{4}-\d{2}-\d{2})
(.*?)
', game_html, re.DOTALL) - if desc_match: - description = desc_match.group(1).strip() - # Nettoyer les entités HTML - description = description.replace(''', "'") - description = description.replace('"', '"') - description = description.replace('&', '&') - logger.info(f"Description trouvée ({len(description)} caractères)") - - # Extraire le genre - genre_match = re.search(r'Genre\(s\): (.*?)
', game_html) - if genre_match: - genre = genre_match.group(1).strip() - logger.info(f"Genre trouvé: {genre}") + if "genres" in game and game["genres"]: + genre_ids = game["genres"] + # Les noms des genres sont dans data.genres + if "genres" in data["data"]: + genre_names = [] + for gid in genre_ids: + if str(gid) in data["data"]["genres"]: + genre_names.append(data["data"]["genres"][str(gid)]["name"]) + if genre_names: + genre = ", ".join(genre_names) + + # Extraire l'image de couverture (boxart) + # Utiliser l'endpoint dédié /v1/Games/Images pour récupérer les images du jeu + image_url = None + try: + images_url = f"{API_BASE_URL}/Games/Images" + images_params = { + "apikey": API_KEY, + "games_id": game_id, + "filter[type]": "boxart" + } - except Exception as e: - logger.warning(f"Erreur lors de la récupération de la page du jeu: {e}") + logger.debug(f"Récupération des images pour game_id={game_id}") + images_response = requests.get(images_url, params=images_params, timeout=10) + + if images_response.status_code == 200: + images_data = images_response.json() + + # Récupérer l'URL de base + base_url_original = "" + if "data" in images_data and "base_url" in images_data["data"]: + base_url_original = images_data["data"]["base_url"].get("original", "") + + # Parcourir les images + if "data" in images_data and "images" in images_data["data"]: + images_dict = images_data["data"]["images"] + + # Les images sont organisées par game_id + if str(game_id) in images_dict: + game_images = images_dict[str(game_id)] + + # Chercher front boxart en priorité + for img in game_images: + if img.get("type") == "boxart" and img.get("side") == "front": + filename = img.get("filename") + if filename: + image_url = f"{base_url_original}{filename}" + logger.info(f"Image front trouvée: {image_url}") + break + + # Si pas de front, prendre n'importe quelle boxart + if not image_url: + for img in game_images: + if img.get("type") == "boxart": + filename = img.get("filename") + if filename: + image_url = f"{base_url_original}{filename}" + logger.info(f"Image boxart trouvée: {image_url}") + break + + # Si toujours rien, prendre la première image + if not image_url and game_images: + filename = game_images[0].get("filename") + if filename: + image_url = f"{base_url_original}{filename}" + logger.info(f"Première image trouvée: {image_url}") + else: + logger.warning(f"Erreur lors de la récupération des images: HTTP {images_response.status_code}") + + except Exception as img_error: + logger.warning(f"Erreur lors de la récupération des images: {img_error}") # Construire le résultat result = { @@ -254,15 +311,16 @@ def get_game_metadata(game_name, platform_name): "release_date": release_date } - # Vérifier qu'on a au moins quelque chose - if not any([image_url, description, genre]): - result["error"] = "Métadonnées incomplètes" + logger.info(f"Métadonnées récupérées: image={bool(image_url)}, desc={bool(description)}, genre={bool(genre)}, date={bool(release_date)}") return result except requests.RequestException as e: - logger.error(f"Erreur lors de la requête: {str(e)}") + logger.error(f"Erreur lors de la requête API: {str(e)}") return {"error": f"Erreur réseau: {str(e)}"} + except Exception as e: + logger.error(f"Erreur inattendue: {str(e)}", exc_info=True) + return {"error": f"Erreur: {str(e)}"} def download_image_to_surface(image_url): diff --git a/pygame/controller_debug.pygame b/pygame/controller_debug.pygame deleted file mode 100644 index cb7615e..0000000 --- a/pygame/controller_debug.pygame +++ /dev/null @@ -1,406 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys -import time -import json -import re -import traceback -from typing import Any, Dict, Tuple, List, Optional - -try: - import pygame # type: ignore -except Exception as e: - print("Pygame is required. Install with: pip install pygame") - raise - - -PROMPTS = [ - # Face buttons - "SOUTH_BUTTON - CONFIRM", # A on Xbox - "EAST_BUTTON - CANCEL", # B on Xbox - "WEST_BUTTON - CLEAR HISTORY / SELECT GAMES", # X on Xbox - "NORTH_BUTTON - HISTORY", # Y on Xbox - # Meta - "START - PAUSE", - "SELECT - FILTER", - # D-Pad - "DPAD_UP - MOVE UP", - "DPAD_DOWN - MOVE DOWN", - "DPAD_LEFT - MOVE LEFT", - "DPAD_RIGHT - MOVE RIGHT", - # Bumpers - "LEFT_BUMPER - LB/L1 - Delete last char", - "RIGHT_BUMPER - RB/R1 - Add space", - # Triggers - "LEFT_TRIGGER - LT/L2 - Page +", - "RIGHT_TRIGGER - RT/R2 - Page -", - # Left stick directions - "JOYSTICK_LEFT_UP - MOVE UP", - "JOYSTICK_LEFT_DOWN - MOVE DOWN", - "JOYSTICK_LEFT_LEFT - MOVE LEFT", - "JOYSTICK_LEFT_RIGHT - MOVE RIGHT", -] - -INPUT_TIMEOUT_SECONDS = 10 # Temps max par entrée avant "ignored" - -# --- 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}") - log("") - log(f"Note: each input will auto-ignore after {INPUT_TIMEOUT_SECONDS}s if not present (e.g. missing L2/R2)") - 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, timeout_sec: int = INPUT_TIMEOUT_SECONDS) -> 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("") - deadline = time.time() + max(1, int(timeout_sec)) - log(f"Press {logical_name} (Wait {timeout_sec}s to skip/ignore) if not present") - # Flush old events - pygame.event.clear() - while True: - # Update window title with countdown if we have a surface - try: - remaining = int(max(0, deadline - time.time())) - if SURFACE is not None: - pygame.display.set_caption(f"Controller Tester — {logical_name} — {remaining}s left") - except Exception: - pass - 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() - # Timeout? - if time.time() >= deadline: - log(f"Ignored {logical_name} (timeout {timeout_sec}s)") - return ("ignored", None) - 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") - elif kind == "ignored": - lines.append(f"{name} = IGNORED\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}") - - -# --- JSON preset generation --- -def sanitize_device_name(name: str) -> str: - s = name.strip().lower() - # Replace non-alphanumeric with underscore - s = re.sub(r"[^a-z0-9]+", "_", s) - s = re.sub(r"_+", "_", s).strip("_") - return s or "controller" - - -def to_json_binding(kind: str, data: Any, display: Optional[str] = None) -> Optional[Dict[str, Any]]: - if kind == "button" and isinstance(data, int): - return {"type": "button", "button": data, **({"display": display} if display else {})} - if kind == "hat" and isinstance(data, (tuple, list)) and len(data) == 2: - val = list(data) - return {"type": "hat", "value": val, **({"display": display} if display else {})} - if kind == "axis" and isinstance(data, dict): - axis = data.get("axis") - direction = data.get("direction") - if isinstance(axis, int) and direction in (-1, 1): - return {"type": "axis", "axis": axis, "direction": int(direction), **({"display": display} if display else {})} - return None - - -def build_controls_json(mapping: Dict[str, Tuple[str, Any]]) -> Dict[str, Any]: - # Map logical prompts to action keys and preferred display labels - prompt_map = { - "SOUTH_BUTTON - CONFIRM": ("confirm", "A"), - "EAST_BUTTON - CANCEL": ("cancel", "B"), - "WEST_BUTTON - CLEAR HISTORY / SELECT GAMES": ("clear_history", "X"), - "NORTH_BUTTON - HISTORY": ("history", "Y"), - "START - PAUSE": ("start", "Start"), - "SELECT - FILTER": ("filter", "Select"), - "DPAD_UP - MOVE UP": ("up", "↑"), - "DPAD_DOWN - MOVE DOWN": ("down", "↓"), - "DPAD_LEFT - MOVE LEFT": ("left", "←"), - "DPAD_RIGHT - MOVE RIGHT": ("right", "→"), - "LEFT_BUMPER - LB/L1 - Delete last char": ("delete", "LB"), - "RIGHT_BUMPER - RB/R1 - Add space": ("space", "RB"), - # Triggers per prompts: LEFT=page_up, RIGHT=page_down - "LEFT_TRIGGER - LT/L2 - Page +": ("page_up", "LT"), - "RIGHT_TRIGGER - RT/R2 - Page -": ("page_down", "RT"), - # Left stick directions (fallbacks for arrows) - "JOYSTICK_LEFT_UP - MOVE UP": ("up", "J↑"), - "JOYSTICK_LEFT_DOWN - MOVE DOWN": ("down", "J↓"), - "JOYSTICK_LEFT_LEFT - MOVE LEFT": ("left", "J←"), - "JOYSTICK_LEFT_RIGHT - MOVE RIGHT": ("right", "J→"), - } - - result: Dict[str, Any] = {} - - # First pass: take direct DPAD/face/meta/bumper/trigger bindings - for prompt, (action, disp) in prompt_map.items(): - if prompt not in mapping: - continue - kind, data = mapping[prompt] - if kind in ("ignored", "skipped"): - continue - # Prefer DPAD over JOYSTICK for directions: handle fallback later - if action in ("up", "down", "left", "right"): - if prompt.startswith("DPAD_"): - b = to_json_binding(kind, data, disp) - if b: - result[action] = b - # Joystick handled as fallback if DPAD missing - else: - b = to_json_binding(kind, data, disp) - if b: - result[action] = b - - # Second pass: fallback to joystick directions if arrows missing - fallbacks = [ - ("JOYSTICK_LEFT_UP - MOVE UP", "up", "J↑"), - ("JOYSTICK_LEFT_DOWN - MOVE DOWN", "down", "J↓"), - ("JOYSTICK_LEFT_LEFT - MOVE LEFT", "left", "J←"), - ("JOYSTICK_LEFT_RIGHT - MOVE RIGHT", "right", "J→"), - ] - for prompt, action, disp in fallbacks: - if action in result: - continue - if prompt in mapping: - kind, data = mapping[prompt] - if kind in ("ignored", "skipped"): - continue - b = to_json_binding(kind, data, disp) - if b: - result[action] = b - - return result - - -def write_controls_json(device_name: str, controls: Dict[str, Any]) -> str: - """Write the generated controls preset JSON in the same folder as this script. - - Also embeds a JSON-safe comment with the device name under the _comment key. - """ - # Same folder as the launched script - base_dir = os.path.dirname(os.path.abspath(__file__)) - fname = f"{sanitize_device_name(device_name)}_controller.json" - out_path = os.path.join(base_dir, fname) - # Include the detected device name for auto-preset matching - payload = {"device": device_name} - payload.update(controls) - try: - with open(out_path, "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=4) - return out_path - except Exception: - return out_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()) - # Build and write ready-to-use JSON controls preset - controls = build_controls_json(mapping) - if controls: - out_json = write_controls_json(js.get_name(), controls) - log(f"Saved JSON preset to: {out_json}") - else: - log("No usable inputs captured to build a JSON preset.") - 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