commit 297fbaf0d26d1670f104c87564ff476252ed3ee9 Author: skymike03 Date: Sun Jul 6 19:47:21 2025 +0200 Initial commit with RGSX project files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..869eb0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +logs/ +images/ +games/ +_pycache_/ +sources.json +gamelist.xml \ No newline at end of file diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..654834f --- /dev/null +++ b/__main__.py @@ -0,0 +1,738 @@ +import os +os.environ["SDL_FBDEV"] = "/dev/fb0" +import pygame +import asyncio +import platform +import subprocess +import math +import logging +import requests +import sys +import json +from display import init_display, draw_loading_screen, draw_error_screen, draw_platform_grid, draw_progress_screen, draw_scrollbar, draw_confirm_dialog, draw_controls, draw_gradient, draw_virtual_keyboard, draw_popup_message, draw_extension_warning, draw_pause_menu, draw_controls_help +from network import test_internet, download_rom, check_extension_before_download, extract_zip +from controls import handle_controls +from controls_mapper import load_controls_config, map_controls, draw_controls_mapping, ACTIONS +from utils import truncate_text_end, load_system_image, load_games +import config + +# Configuration du logging +log_dir = "/userdata/roms/ports/RGSX/logs" +log_file = os.path.join(log_dir, "RGSX.log") +try: + os.makedirs(log_dir, exist_ok=True) + logging.basicConfig( + filename=log_file, + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' + ) +except Exception as e: + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + logging.error(f"Échec de la configuration du logging dans {log_file}: {str(e)}") + +logger = logging.getLogger(__name__) + +# URL du serveur OTA +OTA_SERVER_URL = "https://retrogamesets.fr/softs" +OTA_VERSION_ENDPOINT = f"{OTA_SERVER_URL}/version.json" +OTA_UPDATE_SCRIPT = f"{OTA_SERVER_URL}/rgsx-update.sh" +OTA_data_ZIP = f"{OTA_SERVER_URL}/rgsx-data.zip" + +# Constantes pour la répétition automatique dans pause_menu +REPEAT_DELAY = 300 # Délai initial avant répétition (ms) +REPEAT_INTERVAL = 100 # Intervalle entre répétitions (ms) +REPEAT_ACTION_DEBOUNCE = 50 # Délai anti-rebond pour répétitions (ms) + +# Initialisation de Pygame +pygame.init() +pygame.joystick.init() +pygame.mouse.set_visible(True) + +# Détection système non-PC +def detect_non_pc(): + arch = platform.machine() + try: + result = subprocess.run(["batocera-es-swissknife", "--arch"], capture_output=True, text=True, timeout=2) + if result.returncode == 0: + arch = result.stdout.strip() + logger.debug(f"Architecture via batocera-es-swissknife: {arch}") + except (subprocess.SubprocessError, FileNotFoundError): + logger.debug(f"batocera-es-swissknife non disponible, utilisation de platform.machine(): {arch}") + + is_non_pc = arch not in ["x86_64", "amd64"] + logger.debug(f"Système détecté: {platform.system()}, architecture: {arch}, is_non_pc={is_non_pc}") + return is_non_pc + +config.is_non_pc = detect_non_pc() + +# Initialisation de l’écran +screen = init_display() +pygame.display.set_caption("RGSX") +clock = pygame.time.Clock() + +# Initialisation des polices +try: + config.font = pygame.font.Font("/userdata/roms/ports/RGSX/assets/Pixel-UniCode.ttf", 48) + config.title_font = pygame.font.Font("/userdata/roms/ports/RGSX/assets/Pixel-UniCode.ttf", 60) + config.search_font = pygame.font.Font("/userdata/roms/ports/RGSX/assets/Pixel-UniCode.ttf", 60) + logger.debug("Police Pixel-UniCode chargée") +except: + config.font = pygame.font.SysFont("arial", 48) + config.title_font = pygame.font.SysFont("arial", 60) + config.search_font = pygame.font.SysFont("arial", 60) + logger.debug("Police Arial chargée") +config.progress_font = pygame.font.SysFont("arial", 36) +config.small_font = pygame.font.SysFont("arial", 24) + +# Mise à jour de la résolution dans config +config.screen_width, config.screen_height = pygame.display.get_surface().get_size() +logger.debug(f"Résolution réelle : {config.screen_width}x{config.screen_height}") + +# Initialisation des variables de grille +config.current_page = 0 +config.selected_platform = 0 +config.selected_key = (0, 0) +config.transition_state = "none" + +# Initialisation des variables de répétition +config.repeat_action = None +config.repeat_key = None +config.repeat_start_time = 0 +config.repeat_last_action = 0 + +# Vérification et chargement de la configuration des contrôles +config.controls_config = load_controls_config() +if not config.controls_config: + config.menu_state = "controls_mapping" +else: + config.menu_state = "loading" + +# Initialisation du gamepad +joystick = None +if pygame.joystick.get_count() > 0: + joystick = pygame.joystick.Joystick(0) + joystick.init() + logger.debug("Gamepad initialisé") + +# Initialisation du mixer Pygame +pygame.mixer.pre_init(44100, -16, 2, 4096) +pygame.mixer.init() + +# Dossier musique Batocera +music_folder = "/userdata/roms/ports/RGSX/assets/music" +music_files = [f for f in os.listdir(music_folder) if f.lower().endswith(('.ogg', '.mp3'))] +if music_files: + import random + music_file = random.choice(music_files) + music_path = os.path.join(music_folder, music_file) + logger.debug(f"Lecture de la musique : {music_path}") + pygame.mixer.music.load(music_path) + pygame.mixer.music.set_volume(0.5) + pygame.mixer.music.play(-1) +else: + logger.debug("Aucune musique trouvée dans /userdata/roms/ports/RGSX/assets/music") + +# Fonction pour charger sources.json +def load_sources(): + sources_path = "/userdata/roms/ports/RGSX/sources.json" + logger.debug(f"Chargement de {sources_path}") + try: + with open(sources_path, 'r', encoding='utf-8') as f: + sources = json.load(f) + sources = sorted(sources, key=lambda x: x.get("nom", x.get("platform", "")).lower()) + config.platforms = [source["platform"] for source in sources] + config.platform_dicts = sources + config.platform_names = {source["platform"]: source["nom"] for source in sources} + config.games_count = {platform: 0 for platform in config.platforms} # Initialiser à 0 + # Charger les jeux pour chaque plateforme + for platform in config.platforms: + games = load_games(platform) + config.games_count[platform] = len(games) + logger.debug(f"Jeux chargés pour {platform}: {len(games)} jeux") + logger.debug(f"load_sources: platforms={config.platforms}, platform_names={config.platform_names}, games_count={config.games_count}") + return sources + except Exception as e: + logger.error(f"Erreur lors du chargement de sources.json : {str(e)}") + return [] +# Fonction pour vérifier et appliquer les mises à jour OTA +async def check_for_updates(): + try: + logger.debug("Vérification de la version disponible sur le serveur") + config.current_loading_system = "Mise à jour en cours... Patientez l'ecran reste figé..Puis relancer l'application" + config.loading_progress = 5.0 + config.needs_redraw = True + response = requests.get(OTA_VERSION_ENDPOINT, timeout=5) + response.raise_for_status() + if response.headers.get("content-type") != "application/json": + raise ValueError(f"Le fichier version.json n'est pas un JSON valide (type de contenu : {response.headers.get('content-type')})") + version_data = response.json() + latest_version = version_data.get("version") + logger.debug(f"Version distante : {latest_version}, version locale : {config.app_version}") + + if latest_version != config.app_version: + config.current_loading_system = f"Mise à jour disponible : {latest_version}" + config.loading_progress = 10.0 + config.needs_redraw = True + logger.debug(f"Téléchargement du script de mise à jour : {OTA_UPDATE_SCRIPT}") + + update_script_path = "/userdata/roms/ports/rgsx-update.sh" + logger.debug(f"Téléchargement de {OTA_UPDATE_SCRIPT} vers {update_script_path}") + with requests.get(OTA_UPDATE_SCRIPT, stream=True, timeout=10) as r: + r.raise_for_status() + with open(update_script_path, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + config.loading_progress = min(50.0, config.loading_progress + 5.0) + config.needs_redraw = True + await asyncio.sleep(0) + + config.current_loading_system = "Préparation de la mise à jour..." + config.loading_progress = 60.0 + config.needs_redraw = True + logger.debug(f"Rendre {update_script_path} exécutable") + subprocess.run(["chmod", "+x", update_script_path], check=True) + logger.debug(f"Script {update_script_path} rendu exécutable") + + logger.debug(f"Vérification de l'existence et des permissions de {update_script_path}") + if not os.path.isfile(update_script_path): + logger.error(f"Le script {update_script_path} n'existe pas") + return False, f"Erreur : le script {update_script_path} n'existe pas" + if not os.access(update_script_path, os.X_OK): + logger.error(f"Le script {update_script_path} n'est pas exécutable") + return False, f"Erreur : le script {update_script_path} n'est pas exécutable" + + wrapper_script_path = "/userdata/roms/ports/RGSX/update/run.update" + logger.debug(f"Vérification de l'existence et des permissions de {wrapper_script_path}") + if not os.path.isfile(wrapper_script_path): + logger.error(f"Le script wrapper {wrapper_script_path} n'existe pas") + return False, f"Erreur : le script wrapper {wrapper_script_path} n'existe pas" + if not os.access(wrapper_script_path, os.X_OK): + logger.error(f"Le script wrapper {wrapper_script_path} n'est pas exécutable") + subprocess.run(["chmod", "+x", wrapper_script_path], check=True) + logger.debug(f"Script wrapper {wrapper_script_path} rendu exécutable") + + logger.debug("Désactivation des événements Pygame QUIT") + pygame.event.set_blocked(pygame.QUIT) + + config.current_loading_system = "Application de la mise à jour..." + config.loading_progress = 80.0 + config.needs_redraw = True + logger.debug(f"Exécution du script wrapper : {wrapper_script_path}") + result = os.system(f"{wrapper_script_path} &") + logger.debug(f"Résultat de os.system : {result}") + if result != 0: + logger.error(f"Échec du lancement du script wrapper : code de retour {result}") + return False, f"Échec du lancement du script wrapper : code de retour {result}" + + config.current_loading_system = "Mise à jour déclenchée, redémarrage..." + config.loading_progress = 100.0 + config.needs_redraw = True + logger.debug("Mise à jour déclenchée, arrêt de l'application") + config.update_triggered = True + pygame.quit() + sys.exit(0) + else: + logger.debug("Aucune mise à jour logicielle disponible") + return True, "Aucune mise à jour disponible" + + except Exception as e: + logger.error(f"Erreur OTA : {str(e)}") + return False, f"Erreur lors de la vérification des mises à jour : {str(e)}" + +# Boucle principale + +async def main(): + logger.debug("Début main") + running = True + loading_step = "none" + sources = [] + config.last_state_change_time = 0 + config.debounce_delay = 50 + config.update_triggered = False + last_redraw_time = pygame.time.get_ticks() + + screen = pygame.display.set_mode((1280, 720)) # Initialiser l'écran + clock = pygame.time.Clock() + + while running: + if config.update_triggered: + logger.debug("Mise à jour déclenchée, arrêt de la boucle principale") + break + + current_time = pygame.time.get_ticks() + + # Forcer redraw toutes les 100 ms dans download_progress + if config.menu_state == "download_progress" and current_time - last_redraw_time >= 100: + config.needs_redraw = True + last_redraw_time = current_time + + # Gestion des événements + events = pygame.event.get() + for event in events: + if event.type == pygame.QUIT: + config.menu_state = "confirm_exit" + config.confirm_selection = 0 + config.needs_redraw = True + logger.debug("Événement QUIT détecté, passage à confirm_exit") + continue + + start_config = config.controls_config.get("start", {}) + if start_config and ( + (event.type == pygame.KEYDOWN and start_config.get("type") == "key" and event.key == start_config.get("value")) or + (event.type == pygame.JOYBUTTONDOWN and start_config.get("type") == "button" and event.button == start_config.get("value")) or + (event.type == pygame.JOYAXISMOTION and start_config.get("type") == "axis" and event.axis == start_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == start_config.get("value")[1]) or + (event.type == pygame.JOYHATMOTION and start_config.get("type") == "hat" and event.value == start_config.get("value")) or + (event.type == pygame.MOUSEBUTTONDOWN and start_config.get("type") == "mouse" and event.button == start_config.get("value")) + ): + if config.menu_state not in ["pause_menu", "controls_help", "controls_mapping"]: + config.previous_menu_state = config.menu_state + config.menu_state = "pause_menu" + config.selected_pause_option = 0 + config.needs_redraw = True + logger.debug(f"Ouverture menu pause depuis {config.previous_menu_state}") + continue + + if config.menu_state == "pause_menu": + current_time = pygame.time.get_ticks() + if event.type in (pygame.KEYDOWN, pygame.JOYBUTTONDOWN, pygame.JOYAXISMOTION, pygame.JOYHATMOTION): + up_config = config.controls_config.get("up", {}) + down_config = config.controls_config.get("down", {}) + confirm_config = config.controls_config.get("confirm", {}) + cancel_config = config.controls_config.get("cancel", {}) + + if current_time - config.last_state_change_time < config.debounce_delay: + continue + + if ( + (event.type == pygame.KEYDOWN and up_config and event.key == up_config.get("value")) or + (event.type == pygame.JOYBUTTONDOWN and up_config and up_config.get("type") == "button" and event.button == up_config.get("value")) or + (event.type == pygame.JOYAXISMOTION and up_config and up_config.get("type") == "axis" and event.axis == up_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == up_config.get("value")[1]) or + (event.type == pygame.JOYHATMOTION and up_config and up_config.get("type") == "hat" and event.value == up_config.get("value")) + ): + config.selected_pause_option = max(0, config.selected_pause_option - 1) + config.repeat_action = "up" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + logger.debug(f"Menu pause: Haut, selected_option={config.selected_pause_option}, repeat_action={config.repeat_action}") + elif ( + (event.type == pygame.KEYDOWN and down_config and event.key == down_config.get("value")) or + (event.type == pygame.JOYBUTTONDOWN and down_config and down_config.get("type") == "button" and event.button == down_config.get("value")) or + (event.type == pygame.JOYAXISMOTION and down_config and down_config.get("type") == "axis" and event.axis == down_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == down_config.get("value")[1]) or + (event.type == pygame.JOYHATMOTION and down_config and down_config.get("type") == "hat" and event.value == down_config.get("value")) + ): + config.selected_pause_option = min(2, config.selected_pause_option + 1) + config.repeat_action = "down" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + logger.debug(f"Menu pause: Bas, selected_option={config.selected_pause_option}, repeat_action={config.repeat_action}") + elif ( + (event.type == pygame.KEYDOWN and confirm_config and event.key == confirm_config.get("value")) or + (event.type == pygame.JOYBUTTONDOWN and confirm_config and confirm_config.get("type") == "button" and event.button == confirm_config.get("value")) or + (event.type == pygame.JOYAXISMOTION and confirm_config and confirm_config.get("type") == "axis" and event.axis == confirm_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == confirm_config.get("value")[1]) or + (event.type == pygame.JOYHATMOTION and confirm_config and confirm_config.get("type") == "hat" and event.value == confirm_config.get("value")) + ): + if config.selected_pause_option == 0: + config.menu_state = "controls_help" + config.needs_redraw = True + logger.debug("Menu pause: Aide sélectionnée") + elif config.selected_pause_option == 1: + if map_controls(screen): + config.menu_state = config.previous_menu_state if config.previous_menu_state in ["platform", "game", "download_progress", "download_result", "confirm_exit", "extension_warning"] else "platform" + config.controls_config = load_controls_config() + logger.debug(f"Mappage des contrôles terminé, retour à {config.menu_state}") + else: + config.menu_state = "error" + config.error_message = "Échec du mappage des contrôles" + config.needs_redraw = True + logger.debug("Échec du mappage des contrôles") + elif config.selected_pause_option == 2: + running = False + logger.debug("Menu pause: Quitter sélectionné") + elif ( + (event.type == pygame.KEYDOWN and cancel_config and event.key == cancel_config.get("value")) or + (event.type == pygame.JOYBUTTONDOWN and cancel_config and cancel_config.get("type") == "button" and event.button == cancel_config.get("value")) or + (event.type == pygame.JOYAXISMOTION and cancel_config and cancel_config.get("type") == "axis" and event.axis == cancel_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == cancel_config.get("value")[1]) or + (event.type == pygame.JOYHATMOTION and cancel_config and cancel_config.get("type") == "hat" and event.value == cancel_config.get("value")) + ): + config.menu_state = config.previous_menu_state if config.previous_menu_state in ["platform", "game", "download_progress", "download_result", "confirm_exit", "extension_warning"] else "platform" + config.needs_redraw = True + logger.debug(f"Menu pause: Annulation, retour à {config.menu_state}") + + elif event.type in (pygame.KEYUP, pygame.JOYBUTTONUP): + if ( + (event.type == pygame.KEYUP and is_input_matched(event, "up") or is_input_matched(event, "down")) or + (event.type == pygame.JOYBUTTONUP and is_input_matched(event, "up") or is_input_matched(event, "down")) + ): + config.repeat_action = None + config.repeat_key = None + config.repeat_start_time = 0 + config.needs_redraw = True + logger.debug("Menu pause: Touche relâchée, répétition arrêtée") + + if config.repeat_action in ["up", "down"] and current_time >= config.repeat_start_time: + if current_time - config.repeat_last_action < REPEAT_ACTION_DEBOUNCE: + continue + config.repeat_last_action = current_time + if config.repeat_action == "up": + config.selected_pause_option = max(0, config.selected_pause_option - 1) + config.needs_redraw = True + logger.debug(f"Menu pause: Répétition haut, selected_option={config.selected_pause_option}") + elif config.repeat_action == "down": + config.selected_pause_option = min(2, config.selected_pause_option + 1) + config.needs_redraw = True + logger.debug(f"Menu pause: Répétition bas, selected_option={config.selected_pause_option}") + config.repeat_start_time = current_time + REPEAT_INTERVAL + + continue + + if config.menu_state == "controls_help": + cancel_config = config.controls_config.get("cancel", {}) + if ( + (event.type == pygame.KEYDOWN and cancel_config and event.key == cancel_config.get("value")) or + (event.type == pygame.JOYBUTTONDOWN and cancel_config and cancel_config.get("type") == "button" and event.button == cancel_config.get("value")) + ): + config.menu_state = "pause_menu" + config.needs_redraw = True + logger.debug("Controls_help: Annulation, retour à pause_menu") + continue + + if config.menu_state in ["platform", "game", "error", "confirm_exit", "download_progress", "download_result", "extension_warning"]: + action = handle_controls(event, sources, joystick, screen) + config.needs_redraw = True + if action == "quit": + running = False + logger.debug("Action quit détectée, arrêt de l'application") + elif action == "download" and config.menu_state == "game" and config.filtered_games: + game = config.filtered_games[config.current_game] + game_name = game[0] if isinstance(game, (list, tuple)) else game + platform = config.platforms[config.current_platform] + url = game[1] if isinstance(game, (list, tuple)) and len(game) > 1 else None + if url: + logger.debug(f"Vérification de l'extension pour {game_name}, URL: {url}") + is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name) + if not is_supported: + config.pending_download = (url, platform, game_name, is_zip_non_supported) + config.menu_state = "extension_warning" + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug(f"Extension non reconnue, passage à extension_warning pour {game_name}") + else: + task = asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported)) + config.download_tasks[task] = (task, url, game_name, platform) + config.menu_state = "download_progress" + config.needs_redraw = True + logger.debug(f"Téléchargement démarré pour {game_name}, passage à download_progress") + + # Gestion des téléchargements + if config.download_tasks: + for task_id, (task, url, game_name, platform) in list(config.download_tasks.items()): + if task.done(): + try: + success, message = await task + config.download_result_message = message + config.download_result_error = not success + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + config.download_progress.clear() # Réinitialiser download_progress + config.needs_redraw = True + del config.download_tasks[task_id] + logger.debug(f"Téléchargement terminé: {game_name}, succès={success}, message={message}") + except Exception as e: + config.download_result_message = f"Erreur lors du téléchargement : {str(e)}" + config.download_result_error = True + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + config.download_progress.clear() # Réinitialiser download_progress + config.needs_redraw = True + del config.download_tasks[task_id] + logger.error(f"Erreur dans tâche de téléchargement: {str(e)}") + + # Gestion de la fin du popup download_result + if config.menu_state == "download_result" and current_time - config.download_result_start_time > 3000: + config.menu_state = "game" + config.download_progress.clear() # Réinitialiser download_progress + config.needs_redraw = True + logger.debug(f"Fin popup download_result, retour à {config.menu_state}") + + # Affichage + if config.needs_redraw: + draw_gradient(screen, (28, 37, 38), (47, 59, 61)) + if config.menu_state == "controls_mapping": + draw_controls_mapping(screen, ACTIONS[0], None, False, 0.0) + logger.debug("Rendu initial de draw_controls_mapping") + elif config.menu_state == "loading": + draw_loading_screen(screen) + logger.debug("Rendu de draw_loading_screen") + elif config.menu_state == "error": + draw_error_screen(screen) + logger.debug("Rendu de draw_error_screen") + elif config.menu_state == "platform": + platform = config.platforms[config.selected_platform] + platform_name = config.platform_names.get(platform, platform) + game_count = config.games_count.get(platform, 0) + title_text = f"{platform_name} ({game_count} jeux)" + title_surface = config.title_font.render(title_text, True, (255, 255, 255)) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, 60)) + pygame.draw.rect(screen, (50, 50, 50, 200), title_rect.inflate(40, 20)) + pygame.draw.rect(screen, (255, 255, 255), title_rect.inflate(40, 20), 2) + screen.blit(title_surface, title_rect) + draw_platform_grid(screen) + elif config.menu_state == "game": + platform = config.platforms[config.current_platform] + platform_name = config.platform_names.get(platform, platform) + games = config.filtered_games if config.filter_active or config.search_mode else config.games + game_count = len(games) + if not config.search_mode: + title_text = f"{platform_name} ({game_count} jeux)" + title_surface = config.title_font.render(title_text, True, (255, 255, 255)) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, 60)) + pygame.draw.rect(screen, (50, 50, 50, 200), title_rect.inflate(40, 20)) + pygame.draw.rect(screen, (255, 255, 255), title_rect.inflate(40, 20), 2) + screen.blit(title_surface, title_rect) + margin_top = 150 + line_height = config.font.get_height() + 10 + for i in range(config.scroll_offset, min(config.scroll_offset + config.visible_games, len(games))): + game_name = games[i][0] if isinstance(games[i], (list, tuple)) else games[i] + color = (0, 150, 255) if i == config.current_game else (255, 255, 255) + game_text = truncate_text_end(game_name, config.font, config.screen_width - 40) + text_surface = config.font.render(game_text, True, color) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, margin_top + (i - config.scroll_offset) * line_height)) + screen.blit(text_surface, text_rect) + draw_scrollbar(screen) + if config.search_mode: + search_text = f"Filtrer : {config.search_query}_" + search_surface = config.search_font.render(search_text, True, (255, 255, 255)) + search_rect = search_surface.get_rect(center=(config.screen_width // 2, 60)) + pygame.draw.rect(screen, (50, 50, 50, 200), search_rect.inflate(40, 20)) + pygame.draw.rect(screen, (255, 255, 255), search_rect.inflate(40, 20), 2) + screen.blit(search_surface, search_rect) + if config.is_non_pc: + draw_virtual_keyboard(screen) + elif config.filter_active: + filter_text = f"Filtre actif : {config.search_query}" + filter_surface = config.small_font.render(filter_text, True, (255, 255, 255)) + filter_rect = filter_surface.get_rect(center=(config.screen_width // 2, 100)) + pygame.draw.rect(screen, (50, 50, 50, 200), filter_rect.inflate(40, 20)) + pygame.draw.rect(screen, (255, 255, 255), filter_rect.inflate(40, 20), 2) + screen.blit(filter_surface, filter_rect) + elif config.menu_state == "download_progress": + draw_progress_screen(screen) + logger.debug("Rendu de draw_progress_screen") + elif config.menu_state == "download_result": + draw_popup_message(screen, config.download_result_message, config.download_result_error) + logger.debug("Rendu de draw_popup_message") + elif config.menu_state == "confirm_exit": + draw_confirm_dialog(screen) + logger.debug("Rendu de draw_confirm_dialog") + elif config.menu_state == "extension_warning": + draw_extension_warning(screen) + logger.debug("Rendu de draw_extension_warning") + elif config.menu_state == "pause_menu": + draw_pause_menu(screen, config.selected_pause_option) + logger.debug("Rendu de draw_pause_menu") + elif config.menu_state == "controls_help": + draw_controls_help(screen, config.previous_menu_state) + logger.debug("Rendu de draw_controls_help") + + draw_controls(screen, config.menu_state) + pygame.display.flip() + config.needs_redraw = False + + # Gestion de l'état controls_mapping + if config.menu_state == "controls_mapping": + logger.debug("Avant appel de map_controls") + try: + success = map_controls(screen) + logger.debug(f"map_controls terminé, succès={success}") + if success: + config.controls_config = load_controls_config() + config.menu_state = "loading" + config.needs_redraw = True + logger.debug("Passage à l'état loading après mappage") + else: + config.menu_state = "error" + config.error_message = "Échec du mappage des contrôles" + config.needs_redraw = True + logger.debug("Échec du mappage, passage à l'état error") + except Exception as e: + logger.error(f"Erreur lors de l'appel de map_controls : {str(e)}") + config.menu_state = "error" + config.error_message = f"Erreur dans map_controls: {str(e)}" + config.needs_redraw = True + + # Gestion de l'état loading + elif config.menu_state == "loading": + logger.debug(f"Étape chargement : {loading_step}") + if loading_step == "none": + loading_step = "test_internet" + config.current_loading_system = "Test de connexion..." + config.loading_progress = 0.0 + config.needs_redraw = True + logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}") + elif loading_step == "test_internet": + logger.debug("Exécution de test_internet()") + if test_internet(): + loading_step = "check_ota" + config.current_loading_system = "Mise à jour en cours..." + config.loading_progress = 5.0 + config.needs_redraw = True + logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}") + else: + config.menu_state = "error" + config.error_message = "Pas de connexion Internet. Vérifiez votre réseau." + config.needs_redraw = True + logger.debug(f"Erreur : {config.error_message}") + elif loading_step == "check_ota": + logger.debug("Exécution de check_for_updates()") + success, message = await check_for_updates() + logger.debug(f"Résultat de check_for_updates : success={success}, message={message}") + if not success: + config.menu_state = "error" + config.error_message = message + config.needs_redraw = True + logger.debug(f"Erreur OTA : {message}") + else: + loading_step = "check_data" + config.current_loading_system = "Téléchargement des données ..." + config.loading_progress = 10.0 + config.needs_redraw = True + logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}") + elif loading_step == "check_data": + games_data_dir = "/userdata/roms/ports/RGSX/games" + is_data_empty = not os.path.exists(games_data_dir) or not any(os.scandir(games_data_dir)) + logger.debug(f"Dossier Data directory {games_data_dir} is {'empty' if is_data_empty else 'not empty'}") + + if is_data_empty: + config.current_loading_system = "Téléchargement du Dossier Data initial..." + config.loading_progress = 15.0 + config.needs_redraw = True + logger.debug("Dossier Data vide, début du téléchargement du ZIP") + + try: + zip_path = "/userdata/roms/ports/RGSX.zip" + headers = {'User-Agent': 'Mozilla/5.0'} + with requests.get(OTA_data_ZIP, stream=True, headers=headers, timeout=30) as response: + response.raise_for_status() + total_size = int(response.headers.get('content-length', 0)) + logger.debug(f"Taille totale du ZIP : {total_size} octets") + downloaded = 0 + os.makedirs(os.path.dirname(zip_path), exist_ok=True) + with open(zip_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + config.download_progress[OTA_data_ZIP] = { + "downloaded_size": downloaded, + "total_size": total_size, + "status": "Téléchargement", + "progress_percent": (downloaded / total_size * 100) if total_size > 0 else 0 + } + config.loading_progress = 15.0 + (35.0 * downloaded / total_size) if total_size > 0 else 15.0 + config.needs_redraw = True + await asyncio.sleep(0) + logger.debug(f"ZIP téléchargé : {zip_path}") + + config.current_loading_system = "Extraction du Dossier Data initial..." + config.loading_progress = 50.0 + config.needs_redraw = True + dest_dir = "/userdata/roms/ports/RGSX" + success, message = extract_zip(zip_path, dest_dir, OTA_data_ZIP) + if success: + logger.debug(f"Extraction réussie : {message}") + config.loading_progress = 60.0 + config.needs_redraw = True + else: + raise Exception(f"Échec de l'extraction : {message}") + + except Exception as e: + logger.error(f"Erreur lors du téléchargement/extraction du Dossier Data : {str(e)}") + config.menu_state = "error" + config.error_message = f"Échec du téléchargement/extraction du Dossier Data : {str(e)}" + config.needs_redraw = True + loading_step = "load_sources" + if os.path.exists(zip_path): + os.remove(zip_path) + continue + + if os.path.exists(zip_path): + os.remove(zip_path) + logger.debug(f"Fichier ZIP {zip_path} supprimé") + + loading_step = "load_sources" + config.current_loading_system = "Chargement des systèmes..." + config.loading_progress = 60.0 + config.needs_redraw = True + logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}") + else: + loading_step = "load_sources" + config.current_loading_system = "Chargement des systèmes..." + config.loading_progress = 60.0 + config.needs_redraw = True + logger.debug(f"Dossier Data non vide, passage à {loading_step}") + elif loading_step == "load_sources": + sources = load_sources() + if not sources: + config.menu_state = "error" + config.error_message = "Échec du chargement de sources.json" + config.needs_redraw = True + logger.debug("Erreur : Échec du chargement de sources.json") + else: + config.menu_state = "platform" + config.loading_progress = 0.0 + config.current_loading_system = "" + config.needs_redraw = True + logger.debug(f"Fin chargement, passage à platform, progress={config.loading_progress}") + + # Gestion de l'état de transition + if config.transition_state == "to_game": + config.transition_progress += 1 + if config.transition_progress >= config.transition_duration: + config.menu_state = "game" + config.transition_state = "idle" + config.transition_progress = 0.0 + config.needs_redraw = True + logger.debug("Transition terminée, passage à game") + + clock.tick(60) + await asyncio.sleep(0.01) + + pygame.mixer.music.stop() + pygame.quit() + logger.debug("Application terminée") + + +# Fonction pour vérifier si un événement correspond à une action +def is_input_matched(event, action_name): + if not config.controls_config.get(action_name): + return False + mapping = config.controls_config[action_name] + input_type = mapping["type"] + input_value = mapping["value"] + + if input_type == "key" and event.type == pygame.KEYDOWN: + return event.key == input_value + elif input_type == "button" and event.type == pygame.JOYBUTTONDOWN: + return event.button == input_value + elif input_type == "axis" and event.type == pygame.JOYAXISMOTION: + axis, direction = input_value + return event.axis == axis and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == direction + elif input_type == "hat" and event.type == pygame.JOYHATMOTION: + return event.value == input_value + elif input_type == "mouse" and event.type == pygame.MOUSEBUTTONDOWN: + return event.button == input_value + return False + +if platform.system() == "Emscripten": + asyncio.ensure_future(main()) +else: + if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..4553270 --- /dev/null +++ b/config.py @@ -0,0 +1,59 @@ +import pygame +import os +import logging + +logger = logging.getLogger(__name__) + +# Version actuelle de l'application +app_version = "1.4.0" + + +# Variables d'état +platforms = [] +current_platform = 0 +platform_names = {} # {platform_id: platform_name} +games = [] +current_game = 0 +menu_state = "popup" +confirm_choice = False +scroll_offset = 0 +visible_games = 15 +popup_start_time = 0 +last_progress_update = 0 +needs_redraw = True +transition_state = "idle" +transition_progress = 0.0 +transition_duration = 18 +games_count = {} +download_tasks = {} +download_progress = {} +download_result_message = "" +download_result_error = False +download_result_start_time = 0 +loading_progress = 0.0 +current_loading_system = "" +error_message = "" +repeat_action = None +repeat_start_time = 0 +repeat_last_action = 0 +repeat_key = None +filtered_games = [] +search_mode = False +search_query = "" +filter_active = False +extension_confirm_selection = 0 +pending_download = None +controls_config = {} +selected_pause_option = 0 +previous_menu_state = None + +# Résolution de l'écran +screen_width = 800 +screen_height = 600 + +# Polices +font = None +progress_font = None +title_font = None +search_font = None +small_font = None \ No newline at end of file diff --git a/controls.py b/controls.py new file mode 100644 index 0000000..aa3e72d --- /dev/null +++ b/controls.py @@ -0,0 +1,527 @@ +import pygame +import config +import asyncio +import math +from display import draw_validation_transition +from network import download_rom, check_extension_before_download +from controls_mapper import get_readable_input_name +from utils import load_games # Ajout de l'import +import logging + +logger = logging.getLogger(__name__) + +# Constantes pour la répétition automatique +REPEAT_DELAY = 300 # Délai initial avant répétition (ms) +REPEAT_INTERVAL = 100 # Intervalle entre répétitions (ms) +JOYHAT_DEBOUNCE = 200 # Délai anti-rebond pour JOYHATMOTION (ms) +JOYAXIS_DEBOUNCE = 50 # Délai anti-rebond pour JOYAXISMOTION (ms) +REPEAT_ACTION_DEBOUNCE = 50 # Délai anti-rebond pour répétitions up/down/left/right (ms) + +def is_input_matched(event, action_name): + """Vérifie si l'événement correspond à l'action configurée.""" + if not config.controls_config.get(action_name): + return False + mapping = config.controls_config[action_name] + input_type = mapping["type"] + input_value = mapping["value"] + + event_type = event["type"] if isinstance(event, dict) else event.type + event_key = event.get("key") if isinstance(event, dict) else getattr(event, "key", None) + event_button = event.get("button") if isinstance(event, dict) else getattr(event, "button", None) + event_axis = event.get("axis") if isinstance(event, dict) else getattr(event, "axis", None) + event_value = event.get("value") if isinstance(event, dict) else getattr(event, "value", None) + + if input_type == "key" and event_type in (pygame.KEYDOWN, pygame.KEYUP): + logger.debug(f"Vérification key: event_key={event_key}, input_value={input_value}") + return event_key == input_value + elif input_type == "button" and event_type in (pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP): + logger.debug(f"Vérification button: event_button={event_button}, input_value={input_value}") + return event_button == input_value + elif input_type == "axis" and event_type == pygame.JOYAXISMOTION: + axis, direction = input_value + result = event_axis == axis and abs(event_value) > 0.5 and (1 if event_value > 0 else -1) == direction + logger.debug(f"Vérification axis: event_axis={event_axis}, event_value={event_value}, input_value={input_value}, result={result}") + return result + elif input_type == "hat" and event_type == pygame.JOYHATMOTION: + # Convertir input_value en tuple pour comparaison + input_value_tuple = tuple(input_value) if isinstance(input_value, list) else input_value + logger.debug(f"Vérification hat: event_value={event_value}, input_value={input_value_tuple}") + return event_value == input_value_tuple + elif input_type == "mouse" and event_type == pygame.MOUSEBUTTONDOWN: + logger.debug(f"Vérification mouse: event_button={event_button}, input_value={input_value}") + return event_button == input_value + return False + +def handle_controls(event, sources, joystick, screen): + """Gère un événement clavier/joystick/souris et la répétition automatique. + Retourne 'quit', 'download', ou None. + """ + action = None + current_time = pygame.time.get_ticks() + + # Debounce général + if current_time - config.last_state_change_time < config.debounce_delay: + return action + + # Log des événements reçus + logger.debug(f"Événement reçu: type={event.type}, value={getattr(event, 'value', None)}") + + # --- CLAVIER, MANETTE, SOURIS --- + if event.type in (pygame.KEYDOWN, pygame.JOYBUTTONDOWN, pygame.JOYAXISMOTION, pygame.JOYHATMOTION, pygame.MOUSEBUTTONDOWN): + # Débouncer les événements JOYHATMOTION + if event.type == pygame.JOYHATMOTION: + logger.debug(f"JOYHATMOTION détecté: hat={event.hat}, value={event.value}") + if event.value == (0, 0): # Ignorer les relâchements + return action + if current_time - config.repeat_last_action < JOYHAT_DEBOUNCE: + return action + + # Débouncer les événements JOYAXISMOTION + if event.type == pygame.JOYAXISMOTION and current_time - config.repeat_last_action < JOYAXIS_DEBOUNCE: + return action + + # Quitter l'appli + if event.type == pygame.QUIT: + logger.debug("Événement pygame.QUIT détecté") + return "quit" + + # Vérification des actions mappées + for action_name in ["up", "down", "left", "right"]: + if is_input_matched(event, action_name): + logger.debug(f"Action mappée détectée: {action_name}, input={get_readable_input_name(event)}") + + # Erreur + if config.menu_state == "error": + if is_input_matched(event, "confirm"): + config.menu_state = "loading" + logger.debug("Sortie erreur avec Confirm") + elif is_input_matched(event, "cancel"): + config.menu_state = "confirm_exit" + config.confirm_selection = 0 + + # Plateformes + elif config.menu_state == "platform": + max_index = min(9, len(config.platforms) - config.current_page * 9) - 1 + current_grid_index = config.selected_platform - config.current_page * 9 + row = current_grid_index // 3 + if is_input_matched(event, "down"): + if current_grid_index + 3 <= max_index: + config.selected_platform += 3 + config.repeat_action = "down" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "up"): + if current_grid_index - 3 >= 0: + config.selected_platform -= 3 + config.repeat_action = "up" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "left"): + if current_grid_index % 3 != 0: + config.selected_platform -= 1 + config.repeat_action = "left" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif config.current_page > 0: + config.current_page -= 1 + config.selected_platform = config.current_page * 9 + row * 3 + 2 + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + config.repeat_action = "left" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "right"): + if current_grid_index % 3 != 2 and current_grid_index < max_index: + config.selected_platform += 1 + config.repeat_action = "right" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif (config.current_page + 1) * 9 < len(config.platforms): + config.current_page += 1 + config.selected_platform = config.current_page * 9 + row * 3 + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + config.repeat_action = "right" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "page_down"): + if (config.current_page + 1) * 9 < len(config.platforms): + config.current_page += 1 + config.selected_platform = config.current_page * 9 + row * 3 + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + config.repeat_action = None # Réinitialiser la répétition + config.repeat_key = None + config.repeat_start_time = 0 + config.repeat_last_action = current_time + config.needs_redraw = True + logger.debug("Page suivante, répétition réinitialisée") + elif is_input_matched(event, "progress"): + if config.download_tasks: + config.menu_state = "download_progress" + config.needs_redraw = True + logger.debug("Retour à download_progress depuis platform") + elif is_input_matched(event, "confirm"): + if config.platforms: + config.current_platform = config.selected_platform + config.games = load_games(config.platforms[config.current_platform]) # Appel à load_games depuis utils + config.filtered_games = config.games + config.filter_active = False + config.current_game = 0 + config.scroll_offset = 0 + draw_validation_transition(screen, config.current_platform) # Animation de transition + config.menu_state = "game" + config.needs_redraw = True + logger.debug(f"Plateforme sélectionnée: {config.platforms[config.current_platform]}, {len(config.games)} jeux chargés") + elif is_input_matched(event, "cancel"): + config.menu_state = "confirm_exit" + config.confirm_selection = 0 + + # Jeux + elif config.menu_state == "game": + if config.search_mode: + if config.is_non_pc: + keyboard_layout = [ + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], + ['A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + ['Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M'], + ['W', 'X', 'C', 'V', 'B', 'N'] + ] + row, col = config.selected_key + max_row = len(keyboard_layout) - 1 + max_col = len(keyboard_layout[row]) - 1 + if is_input_matched(event, "up"): + if row > 0: + config.selected_key = (row - 1, min(col, len(keyboard_layout[row - 1]) - 1)) + config.repeat_action = "up" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "down"): + if row < max_row: + config.selected_key = (row + 1, min(col, len(keyboard_layout[row + 1]) - 1)) + config.repeat_action = "down" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "left"): + if col > 0: + config.selected_key = (row, col - 1) + config.repeat_action = "left" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "right"): + if col < max_col: + config.selected_key = (row, col + 1) + config.repeat_action = "right" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "confirm"): + key = keyboard_layout[row][col] + if len(config.search_query) < 50: + config.search_query += key.lower() + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] if config.search_query else config.games + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + elif is_input_matched(event, "delete"): + config.search_query = config.search_query[:-1] + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] if config.search_query else config.games + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + elif is_input_matched(event, "space"): + config.search_query += " " + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] if config.search_query else config.games + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + elif is_input_matched(event, "cancel"): + config.search_mode = False + config.search_query = "" + config.filtered_games = config.games + config.filter_active = False + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug("Filtre annulé") + elif is_input_matched(event, "filter"): + config.search_mode = False + config.filter_active = bool(config.search_query) + config.needs_redraw = True + else: + if is_input_matched(event, "confirm"): + config.search_mode = False + config.filter_active = bool(config.search_query) + config.needs_redraw = True + elif is_input_matched(event, "cancel"): + config.search_mode = False + config.search_query = "" + config.filtered_games = config.games + config.filter_active = False + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + logger.debug("Filtre annulé") + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_BACKSPACE: + config.search_query = config.search_query[:-1] + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] if config.search_query else config.games + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + elif event.key == pygame.K_SPACE: + config.search_query += " " + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] if config.search_query else config.games + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + elif event.unicode.isprintable() and len(config.search_query) < 50: + config.search_query += event.unicode + config.filtered_games = [game for game in config.games if config.search_query.lower() in game[0].lower()] if config.search_query else config.games + config.current_game = 0 + config.scroll_offset = 0 + config.needs_redraw = True + else: + if is_input_matched(event, "down"): + config.current_game = min(config.current_game + 1, len(config.filtered_games) - 1) + if config.current_game >= config.scroll_offset + config.visible_games: + config.scroll_offset += 1 + config.repeat_action = "down" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "up"): + config.current_game = max(config.current_game - 1, 0) + if config.current_game < config.scroll_offset: + config.scroll_offset -= 1 + config.repeat_action = "up" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "page_up"): + config.current_game = max(config.current_game - config.visible_games, 0) + config.scroll_offset = max(config.scroll_offset - config.visible_games, 0) + config.repeat_action = "page_up" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "page_down"): + config.current_game = min(config.current_game + config.visible_games, len(config.filtered_games) - 1) + config.scroll_offset = min(config.scroll_offset + config.visible_games, len(config.filtered_games) - config.visible_games) + config.repeat_action = "page_down" + config.repeat_start_time = current_time + REPEAT_DELAY + config.repeat_last_action = current_time + config.repeat_key = event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION else event.value + config.needs_redraw = True + elif is_input_matched(event, "confirm"): + if config.filtered_games: + action = "download" + elif is_input_matched(event, "filter"): + config.search_mode = True + config.search_query = "" + config.filtered_games = config.games + config.selected_key = (0, 0) + config.needs_redraw = True + logger.debug("Entrée en mode recherche") + elif is_input_matched(event, "cancel"): + config.menu_state = "platform" + config.current_game = 0 + config.scroll_offset = 0 + config.filter_active = False + config.filtered_games = config.games + config.needs_redraw = True + logger.debug("Retour à platform, filtre réinitialisé") + elif is_input_matched(event, "progress"): + if config.download_tasks: + config.menu_state = "download_progress" + config.needs_redraw = True + logger.debug("Retour à download_progress depuis game") + + # Download progress + elif config.menu_state == "download_progress": + if is_input_matched(event, "cancel"): + if config.download_tasks: + task = list(config.download_tasks.keys())[0] + config.download_tasks[task][0].cancel() + url = config.download_tasks[task][1] + game_name = config.download_tasks[task][2] + if url in config.download_progress: + del config.download_progress[url] + del config.download_tasks[task] + config.download_result_message = f"Téléchargement annulé : {game_name}" + config.download_result_error = True + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + elif is_input_matched(event, "progress"): + config.menu_state = "game" + config.needs_redraw = True + logger.debug("Retour à game depuis download_progress") + + # Confirmation de sortie + elif config.menu_state == "confirm_exit": + if is_input_matched(event, "left"): + config.confirm_selection = 1 + config.needs_redraw = True + logger.debug("Sélection Oui") + elif is_input_matched(event, "right"): + config.confirm_selection = 0 + config.needs_redraw = True + logger.debug("Sélection Non") + elif is_input_matched(event, "confirm"): + if config.confirm_selection == 1: + logger.debug("Retour de 'quit' pour fermer l'application") + return "quit" + else: + config.menu_state = "platform" + config.needs_redraw = True + logger.debug("Retour à platform depuis confirm_exit") + elif is_input_matched(event, "cancel"): + config.menu_state = "platform" + config.needs_redraw = True + logger.debug("Annulation confirm_exit") + + # Avertissement d'extension + elif config.menu_state == "extension_warning": + if is_input_matched(event, "left"): + config.extension_confirm_selection = 1 + config.needs_redraw = True + logger.debug("Sélection Oui (extension_warning)") + elif is_input_matched(event, "right"): + config.extension_confirm_selection = 0 + config.needs_redraw = True + logger.debug("Sélection Non (extension_warning)") + elif is_input_matched(event, "confirm"): + if config.extension_confirm_selection == 1: + if config.pending_download: + url, platform, game_name, is_zip_non_supported = config.pending_download + task = asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported=is_zip_non_supported)) + config.download_tasks[task] = (task, url, game_name, platform) + config.menu_state = "download_progress" + config.pending_download = None + config.needs_redraw = True + else: + config.menu_state = "game" + config.needs_redraw = True + else: + config.menu_state = "game" + config.pending_download = None + config.needs_redraw = True + logger.debug("Téléchargement annulé (extension_warning)") + elif is_input_matched(event, "cancel"): + config.menu_state = "game" + config.pending_download = None + config.needs_redraw = True + logger.debug("Annulation extension_warning") + + # Résultat téléchargement + elif config.menu_state == "download_result": + if is_input_matched(event, "confirm"): + config.menu_state = "game" + config.needs_redraw = True + logger.debug("Retour à game depuis download_result") + + # Enregistrer la touche pour la répétition + if config.repeat_action in ["up", "down", "page_up", "page_down", "left", "right"]: + if event.type == pygame.KEYDOWN: + config.repeat_key = event.key + elif event.type == pygame.JOYBUTTONDOWN: + config.repeat_key = event.button + elif event.type == pygame.JOYAXISMOTION: + config.repeat_key = (event.axis, 1 if event.value > 0 else -1) + elif event.type == pygame.JOYHATMOTION: + config.repeat_key = event.value + config.repeat_last_action = current_time + + elif event.type in (pygame.KEYUP, pygame.JOYBUTTONUP): + if config.menu_state in ("game", "platform") and is_input_matched(event, config.repeat_action): + config.repeat_action = None + config.repeat_key = None + config.repeat_start_time = 0 + config.needs_redraw = True + + # Gestion de la répétition automatique + if config.menu_state in ("game", "platform") and config.repeat_action: + if current_time >= config.repeat_start_time: + if config.repeat_action in ["up", "down", "left", "right"] and current_time - config.repeat_last_action < REPEAT_ACTION_DEBOUNCE: + return action + + last_repeat_time = config.repeat_start_time - REPEAT_INTERVAL + config.repeat_last_action = current_time + if config.menu_state == "game": + if config.repeat_action == "down": + config.current_game = min(config.current_game + 1, len(config.filtered_games) - 1) + if config.current_game >= config.scroll_offset + config.visible_games: + config.scroll_offset += 1 + config.needs_redraw = True + elif config.repeat_action == "up": + config.current_game = max(config.current_game - 1, 0) + if config.current_game < config.scroll_offset: + config.scroll_offset -= 1 + config.needs_redraw = True + elif config.repeat_action == "page_down": + config.current_game = min(config.current_game + config.visible_games, len(config.filtered_games) - 1) + config.scroll_offset = min(config.scroll_offset + config.visible_games, len(config.filtered_games) - config.visible_games) + config.needs_redraw = True + elif config.repeat_action == "page_up": + config.current_game = max(config.current_game - config.visible_games, 0) + config.scroll_offset = max(config.scroll_offset - config.visible_games, 0) + config.needs_redraw = True + elif config.menu_state == "platform": + max_index = min(9, len(config.platforms) - config.current_page * 9) - 1 + current_grid_index = config.selected_platform - config.current_page * 9 + row = current_grid_index // 3 + if config.repeat_action == "down": + if current_grid_index + 3 <= max_index: + config.selected_platform += 3 + config.needs_redraw = True + elif config.repeat_action == "up": + if current_grid_index - 3 >= 0: + config.selected_platform -= 3 + config.needs_redraw = True + elif config.repeat_action == "left": + if current_grid_index % 3 != 0: + config.selected_platform -= 1 + config.needs_redraw = True + elif config.current_page > 0: + config.current_page -= 1 + config.selected_platform = config.current_page * 9 + row * 3 + 2 + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + config.needs_redraw = True + elif config.repeat_action == "right": + if current_grid_index % 3 != 2 and current_grid_index < max_index: + config.selected_platform += 1 + config.needs_redraw = True + elif (config.current_page + 1) * 9 < len(config.platforms): + config.current_page += 1 + config.selected_platform = config.current_page * 9 + row * 3 + if config.selected_platform >= len(config.platforms): + config.selected_platform = len(config.platforms) - 1 + config.needs_redraw = True + config.repeat_start_time = last_repeat_time + REPEAT_INTERVAL + if config.repeat_start_time < current_time: + config.repeat_start_time = current_time + REPEAT_INTERVAL + + return action \ No newline at end of file diff --git a/controls_mapper.py b/controls_mapper.py new file mode 100644 index 0000000..eef10ec --- /dev/null +++ b/controls_mapper.py @@ -0,0 +1,476 @@ +import pygame +import json +import os +import logging +import config +from display import draw_gradient + +logger = logging.getLogger(__name__) + +# Chemin du fichier de configuration des contrôles +CONTROLS_CONFIG_PATH = "/userdata/saves/ports/rgsx/controls.json" + +# Actions internes de RGSX à mapper +ACTIONS = [ + {"name": "confirm", "display": "Confirmer", "description": "Valider (ex: A, Entrée)"}, + {"name": "cancel", "display": "Annuler", "description": "Annuler/Retour (ex: B, RetourArrière)"}, + {"name": "up", "display": "Haut", "description": "Naviguer vers le haut"}, + {"name": "down", "display": "Bas", "description": "Naviguer vers le bas"}, + {"name": "left", "display": "Gauche", "description": "Naviguer à gauche"}, + {"name": "right", "display": "Droite", "description": "Naviguer à droite"}, + {"name": "page_up", "display": "Page Précédente", "description": "Page précédente (ex: PageUp, LB)"}, + {"name": "page_down", "display": "Page Suivante", "description": "Page suivante (ex: PageDown, RB)"}, + {"name": "progress", "display": "Progression", "description": "Voir progression (ex: X)"}, + {"name": "filter", "display": "Filtrer", "description": "Ouvrir filtre (ex: F, Select)"}, + {"name": "delete", "display": "Supprimer", "description": "Supprimer caractère (ex: LT, Suppr)"}, + {"name": "space", "display": "Espace", "description": "Ajouter espace (ex: RT, Espace)"}, + {"name": "start", "display": "Start", "description": "Ouvrir le menu pause (ex: Start, AltGr)"}, +] + +# Mappage des valeurs SDL vers les constantes Pygame +SDL_TO_PYGAME_KEY = { + 1073741906: pygame.K_UP, # Flèche Haut + 1073741905: pygame.K_DOWN, # Flèche Bas + 1073741904: pygame.K_LEFT, # Flèche Gauche + 1073741903: pygame.K_RIGHT, # Flèche Droite + 1073742050: pygame.K_LALT, # Alt gauche + 1073742051: pygame.K_RSHIFT, # Alt droit + 1073742049: pygame.K_LCTRL, # Ctrl gauche + 1073742053: pygame.K_RCTRL, # Ctrl droit + 1073742048: pygame.K_LSHIFT, # Shift gauche + 1073742054: pygame.K_RALT, # Shift droit +} + +# Noms lisibles pour les touches clavier +KEY_NAMES = { + pygame.K_RETURN: "Entrée", + pygame.K_ESCAPE: "Échap", + pygame.K_SPACE: "Espace", + pygame.K_UP: "Flèche Haut", + pygame.K_DOWN: "Flèche Bas", + pygame.K_LEFT: "Flèche Gauche", + pygame.K_RIGHT: "Flèche Droite", + pygame.K_BACKSPACE: "Retour Arrière", + pygame.K_TAB: "Tab", + pygame.K_LALT: "Alt", + pygame.K_RALT: "AltGR", + pygame.K_LCTRL: "LCtrl", + pygame.K_RCTRL: "RCtrl", + pygame.K_LSHIFT: "LShift", + pygame.K_RSHIFT: "RShift", + pygame.K_LMETA: "LMeta", + pygame.K_RMETA: "RMeta", + pygame.K_CAPSLOCK: "Verr Maj", + pygame.K_NUMLOCK: "Verr Num", + pygame.K_SCROLLOCK: "Verr Déf", + pygame.K_a: "A", + pygame.K_b: "B", + pygame.K_c: "C", + pygame.K_d: "D", + pygame.K_e: "E", + pygame.K_f: "F", + pygame.K_g: "G", + pygame.K_h: "H", + pygame.K_i: "I", + pygame.K_j: "J", + pygame.K_k: "K", + pygame.K_l: "L", + pygame.K_m: "M", + pygame.K_n: "N", + pygame.K_o: "O", + pygame.K_p: "P", + pygame.K_q: "Q", + pygame.K_r: "R", + pygame.K_s: "S", + pygame.K_t: "T", + pygame.K_u: "U", + pygame.K_v: "V", + pygame.K_w: "W", + pygame.K_x: "X", + pygame.K_y: "Y", + pygame.K_z: "Z", + pygame.K_0: "0", + pygame.K_1: "1", + pygame.K_2: "2", + pygame.K_3: "3", + pygame.K_4: "4", + pygame.K_5: "5", + pygame.K_6: "6", + pygame.K_7: "7", + pygame.K_8: "8", + pygame.K_9: "9", + pygame.K_KP0: "Pavé 0", + pygame.K_KP1: "Pavé 1", + pygame.K_KP2: "Pavé 2", + pygame.K_KP3: "Pavé 3", + pygame.K_KP4: "Pavé 4", + pygame.K_KP5: "Pavé 5", + pygame.K_KP6: "Pavé 6", + pygame.K_KP7: "Pavé 7", + pygame.K_KP8: "Pavé 8", + pygame.K_KP9: "Pavé 9", + pygame.K_KP_PERIOD: "Pavé .", + pygame.K_KP_DIVIDE: "Pavé /", + pygame.K_KP_MULTIPLY: "Pavé *", + pygame.K_KP_MINUS: "Pavé -", + pygame.K_KP_PLUS: "Pavé +", + pygame.K_KP_ENTER: "Pavé Entrée", + pygame.K_KP_EQUALS: "Pavé =", + pygame.K_F1: "F1", + pygame.K_F2: "F2", + pygame.K_F3: "F3", + pygame.K_F4: "F4", + pygame.K_F5: "F5", + pygame.K_F6: "F6", + pygame.K_F7: "F7", + pygame.K_F8: "F8", + pygame.K_F9: "F9", + pygame.K_F10: "F10", + pygame.K_F11: "F11", + pygame.K_F12: "F12", + pygame.K_F13: "F13", + pygame.K_F14: "F14", + pygame.K_F15: "F15", + pygame.K_INSERT: "Inser", + pygame.K_DELETE: "Suppr", + pygame.K_HOME: "Début", + pygame.K_END: "Fin", + pygame.K_PAGEUP: "Page Haut", + pygame.K_PAGEDOWN: "Page Bas", + pygame.K_PRINT: "Impr Écran", + pygame.K_SYSREQ: "SysReq", + pygame.K_BREAK: "Pause", + pygame.K_PAUSE: "Pause", + pygame.K_BACKQUOTE: "`", + pygame.K_MINUS: "-", + pygame.K_EQUALS: "=", + pygame.K_LEFTBRACKET: "[", + pygame.K_RIGHTBRACKET: "]", + pygame.K_BACKSLASH: "\\", + pygame.K_SEMICOLON: ";", + pygame.K_QUOTE: "'", + pygame.K_COMMA: ",", + pygame.K_PERIOD: ".", + pygame.K_SLASH: "/", +} + +# Noms lisibles pour les boutons de manette +BUTTON_NAMES = { + 0: "A", + 1: "B", + 2: "X", + 3: "Y", + 4: "LB", + 5: "RB", + 6: "LT", + 7: "RT", + 8: "Select", + 9: "Start", +} + +# Noms pour les axes de joystick +AXIS_NAMES = { + (0, 1): "Joy G Haut", + (0, -1): "Joy G Bas", + (1, 1): "Joy G Gauche", + (1, -1): "Joy G Droite", + (2, 1): "Joy D Haut", + (2, -1): "Joy D Bas", + (3, 1): "Joy D Gauche", + (3, -1): "Joy D Droite", +} + +# Noms pour la croix directionnelle +HAT_NAMES = { + (0, 1): "D-Pad Haut", + (0, -1): "D-Pad Bas", + (-1, 0): "D-Pad Gauche", + (1, 0): "D-Pad Droite", +} + +# Noms pour les boutons de souris +MOUSE_BUTTON_NAMES = { + 1: "Clic Gauche", + 2: "Clic Milieu", + 3: "Clic Droit", +} + +# Durée de maintien pour valider une entrée (en millisecondes) +HOLD_DURATION = 1000 + +def load_controls_config(): + """Charge la configuration des contrôles depuis controls.json.""" + try: + if os.path.exists(CONTROLS_CONFIG_PATH): + with open(CONTROLS_CONFIG_PATH, "r") as f: + config = json.load(f) + logger.debug(f"Configuration des contrôles chargée : {config}") + return config + else: + logger.debug("Aucun fichier controls.json trouvé, configuration par défaut.") + return {} + except Exception as e: + logger.error(f"Erreur lors du chargement de controls.json : {e}") + return {} + +def save_controls_config(controls_config): + """Enregistre la configuration des contrôles dans controls.json.""" + try: + os.makedirs(os.path.dirname(CONTROLS_CONFIG_PATH), exist_ok=True) + with open(CONTROLS_CONFIG_PATH, "w") as f: + json.dump(controls_config, f, indent=4) + logger.debug(f"Configuration des contrôles enregistrée : {controls_config}") + except Exception as e: + logger.error(f"Erreur lors de l'enregistrement de controls.json : {e}") + +def get_readable_input_name(event): + """Retourne un nom lisible pour une entrée (touche, bouton, axe, hat, ou souris).""" + if event.type == pygame.KEYDOWN: + key_value = SDL_TO_PYGAME_KEY.get(event.key, event.key) + return KEY_NAMES.get(key_value, pygame.key.name(key_value) or f"Touche {key_value}") + elif event.type == pygame.JOYBUTTONDOWN: + return BUTTON_NAMES.get(event.button, f"Bouton {event.button}") + elif event.type == pygame.JOYAXISMOTION: + if abs(event.value) > 0.5: # Seuil pour détecter un mouvement significatif + return AXIS_NAMES.get((event.axis, 1 if event.value > 0 else -1), f"Axe {event.axis}") + elif event.type == pygame.JOYHATMOTION: + return HAT_NAMES.get(event.value, f"D-Pad {event.value}") + elif event.type == pygame.MOUSEBUTTONDOWN: + return MOUSE_BUTTON_NAMES.get(event.button, f"Souris Bouton {event.button}") + return "Inconnu" + +def map_controls(screen): + """Interface de mappage des contrôles avec validation par maintien de 3 secondes.""" + controls_config = load_controls_config() + current_action_index = 0 + current_input = None + input_held_time = 0 + last_input_name = None + last_frame_time = pygame.time.get_ticks() + config.needs_redraw = True + + # Initialiser l'état des boutons et axes pour suivre les relâchements + held_keys = set() + held_buttons = set() + held_axes = {} # {axis: direction} + held_hats = {} # {hat: value} + held_mouse_buttons = set() + + while current_action_index < len(ACTIONS): + if config.needs_redraw: + progress = min(input_held_time / HOLD_DURATION, 1.0) if current_input else 0.0 + draw_controls_mapping(screen, ACTIONS[current_action_index], last_input_name, current_input is not None, progress) + pygame.display.flip() + config.needs_redraw = False + + current_time = pygame.time.get_ticks() + delta_time = current_time - last_frame_time + last_frame_time = current_time + + events = pygame.event.get() + for event in events: + if event.type == pygame.QUIT: + return False + + # Détecter les relâchements pour réinitialiser + if event.type == pygame.KEYUP: + if event.key in held_keys: + held_keys.remove(event.key) + if current_input and current_input["type"] == "key" and current_input["value"] == event.key: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Touche relâchée: {event.key}") + elif event.type == pygame.JOYBUTTONUP: + if event.button in held_buttons: + held_buttons.remove(event.button) + if current_input and current_input["type"] == "button" and current_input["value"] == event.button: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Bouton relâché: {event.button}") + elif event.type == pygame.MOUSEBUTTONUP: + if event.button in held_mouse_buttons: + held_mouse_buttons.remove(event.button) + if current_input and current_input["type"] == "mouse" and current_input["value"] == event.button: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Bouton souris relâché: {event.button}") + elif event.type == pygame.JOYAXISMOTION: + if abs(event.value) < 0.5: # Axe revenu à la position neutre + if event.axis in held_axes: + del held_axes[event.axis] + if current_input and current_input["type"] == "axis" and current_input["value"][0] == event.axis: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Axe relâché: {event.axis}") + elif event.type == pygame.JOYHATMOTION: + if event.value == (0, 0): # D-Pad revenu à la position neutre + if event.hat in held_hats: + del held_hats[event.hat] + if current_input and current_input["type"] == "hat" and current_input["value"] == event.value: + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"D-Pad relâché: {event.hat}") + + # Détecter les nouvelles entrées + if event.type in (pygame.KEYDOWN, pygame.JOYBUTTONDOWN, pygame.JOYAXISMOTION, pygame.JOYHATMOTION, pygame.MOUSEBUTTONDOWN): + input_name = get_readable_input_name(event) + if input_name != "Inconnu": + input_type = { + pygame.KEYDOWN: "key", + pygame.JOYBUTTONDOWN: "button", + pygame.JOYAXISMOTION: "axis", + pygame.JOYHATMOTION: "hat", + pygame.MOUSEBUTTONDOWN: "mouse", + }[event.type] + input_value = ( + SDL_TO_PYGAME_KEY.get(event.key, event.key) if event.type == pygame.KEYDOWN else + event.button if event.type == pygame.JOYBUTTONDOWN else + (event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION and abs(event.value) > 0.5 else + event.value if event.type == pygame.JOYHATMOTION else + event.button + ) + + # Vérifier si l'entrée est nouvelle ou différente + if (current_input is None or + (input_type == "key" and current_input["value"] != input_value) or + (input_type == "button" and current_input["value"] != input_value) or + (input_type == "axis" and current_input["value"] != input_value) or + (input_type == "hat" and current_input["value"] != input_value) or + (input_type == "mouse" and current_input["value"] != input_value)): + current_input = {"type": input_type, "value": input_value} + input_held_time = 0 + last_input_name = input_name + config.needs_redraw = True + logger.debug(f"Nouvelle entrée détectée: {input_type}:{input_value} ({input_name})") + + # Mettre à jour les entrées maintenues + if input_type == "key": + held_keys.add(input_value) + elif input_type == "button": + held_buttons.add(input_value) + elif input_type == "axis": + held_axes[input_value[0]] = input_value[1] + elif input_type == "hat": + held_hats[event.hat] = input_value + elif input_type == "mouse": + held_mouse_buttons.add(input_value) + + # Sauter à l'action suivante avec Échap + if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: + action_name = ACTIONS[current_action_index]["name"] + controls_config[action_name] = {} # Marquer comme non mappé + current_action_index += 1 + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + logger.debug(f"Action {action_name} ignorée avec Échap, passage à l'action suivante: {ACTIONS[current_action_index]['name'] if current_action_index < len(ACTIONS) else 'fin'}") + + # Mettre à jour le temps de maintien + if current_input: + input_held_time += delta_time + if input_held_time >= HOLD_DURATION: + action_name = ACTIONS[current_action_index]["name"] + logger.debug(f"Entrée validée pour {action_name}: {current_input['type']}:{current_input['value']} ({last_input_name})") + controls_config[action_name] = { + "type": current_input["type"], + "value": current_input["value"], + "display": last_input_name + } + current_action_index += 1 + current_input = None + input_held_time = 0 + last_input_name = None + config.needs_redraw = True + config.needs_redraw = True + + pygame.time.wait(10) + + save_controls_config(controls_config) + config.controls_config = controls_config + return True + +def draw_controls_mapping(screen, action, last_input, waiting_for_input, hold_progress): + """Affiche l'interface de mappage des contrôles avec une barre de progression pour le maintien.""" + draw_gradient(screen, (28, 37, 38), (47, 59, 61)) + + max_width = config.screen_width // 1.2 + padding_horizontal = 40 + padding_vertical = 30 + padding_between = 10 + border_radius = 24 + border_width = 4 + shadow_offset = 8 + + # Instructions + instruction_text = f"Maintenez une touche/bouton pendant 3s pour '{action['display']}'" + description_text = action['description'] + skip_text = "Appuyez sur Échap pour passer" + instruction_surface = config.font.render(instruction_text, True, (255, 255, 255)) + description_surface = config.font.render(description_text, True, (200, 200, 200)) + skip_surface = config.font.render(skip_text, True, (255, 255, 255)) + instruction_width, instruction_height = instruction_surface.get_size() + description_width, description_height = description_surface.get_size() + skip_width, skip_height = skip_surface.get_size() + + # Input détecté + input_text = last_input or (f"En attente d'une entrée..." if waiting_for_input else "Maintenez une touche/bouton") + input_surface = config.font.render(input_text, True, (0, 255, 0) if last_input else (255, 255, 255)) + input_width, input_height = input_surface.get_size() + + # Dimensions de la popup + text_width = max(instruction_width, description_width, input_width, skip_width) + text_height = instruction_height + description_height + input_height + skip_height + 3 * padding_between + popup_width = text_width + 2 * padding_horizontal + popup_height = text_height + 40 + 2 * padding_vertical # +40 pour la barre de progression + popup_x = (config.screen_width - popup_width) // 2 + popup_y = (config.screen_height - popup_height) // 2 + + # Ombre portée + shadow_rect = pygame.Rect(popup_x + shadow_offset, popup_y + shadow_offset, popup_width, popup_height) + shadow_surface = pygame.Surface((popup_width, popup_height), pygame.SRCALPHA) + pygame.draw.rect(shadow_surface, (0, 0, 0, 100), shadow_surface.get_rect(), border_radius=border_radius) + screen.blit(shadow_surface, shadow_rect.topleft) + + # Fond semi-transparent + popup_rect = pygame.Rect(popup_x, popup_y, popup_width, popup_height) + popup_surface = pygame.Surface((popup_width, popup_height), pygame.SRCALPHA) + pygame.draw.rect(popup_surface, (30, 30, 30, 220), popup_surface.get_rect(), border_radius=border_radius) + screen.blit(popup_surface, popup_rect.topleft) + + # Bordure blanche + pygame.draw.rect(screen, (255, 255, 255), popup_rect, border_width, border_radius=border_radius) + + # Afficher les textes + start_y = popup_y + padding_vertical + instruction_rect = instruction_surface.get_rect(center=(config.screen_width // 2, start_y + instruction_height // 2)) + screen.blit(instruction_surface, instruction_rect) + start_y += instruction_height + padding_between + description_rect = description_surface.get_rect(center=(config.screen_width // 2, start_y + description_height // 2)) + screen.blit(description_surface, description_rect) + start_y += description_height + padding_between + input_rect = input_surface.get_rect(center=(config.screen_width // 2, start_y + input_height // 2)) + screen.blit(input_surface, input_rect) + start_y += input_height + padding_between + skip_rect = skip_surface.get_rect(center=(config.screen_width // 2, start_y + skip_height // 2)) + screen.blit(skip_surface, skip_rect) + + # Barre de progression pour le maintien + bar_width = 200 + bar_height = 20 + bar_x = (config.screen_width - bar_width) // 2 + bar_y = start_y + skip_height + 20 + pygame.draw.rect(screen, (100, 100, 100), (bar_x, bar_y, bar_width, bar_height)) + progress_width = bar_width * hold_progress + pygame.draw.rect(screen, (0, 255, 0), (bar_x, bar_y, progress_width, bar_height)) + pygame.draw.rect(screen, (255, 255, 255), (bar_x, bar_y, bar_width, bar_height), 2) \ No newline at end of file diff --git a/display.py b/display.py new file mode 100644 index 0000000..ce0b79f --- /dev/null +++ b/display.py @@ -0,0 +1,548 @@ +import pygame +import config +import math +from utils import truncate_text_end, wrap_text, load_system_image, load_games +import logging + +logger = logging.getLogger(__name__) + +def init_display(): + """Initialise l’écran Pygame.""" + screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN) + return screen + +def draw_gradient(screen, top_color, bottom_color): + """Dessine un fond dégradé vertical.""" + height = screen.get_height() + top_color = pygame.Color(*top_color) + bottom_color = pygame.Color(*bottom_color) + for y in range(height): + ratio = y / height + color = top_color.lerp(bottom_color, ratio) + pygame.draw.line(screen, color, (0, y), (screen.get_width(), y)) + +def draw_loading_screen(screen): + """Affiche l’écran de chargement avec le disclaimer en haut, le texte de chargement et la barre de progression.""" + disclaimer_lines = [ + "Bienvenue dans RGSX", + "It's dangerous to go alone, take all you need!", + "Mais ne téléchargez que des jeux", + "dont vous possédez les originaux !" + ] + + margin_horizontal = 20 + padding_vertical = 20 + padding_between = 8 + border_radius = 16 + border_width = 3 + shadow_offset = 6 + + line_height = config.font.get_height() + padding_between + total_height = line_height * len(disclaimer_lines) - padding_between + rect_width = config.screen_width - 2 * margin_horizontal + rect_height = total_height + 2 * padding_vertical + rect_x = margin_horizontal + rect_y = 20 + + shadow_rect = pygame.Rect(rect_x + shadow_offset, rect_y + shadow_offset, rect_width, rect_height) + shadow_surface = pygame.Surface((rect_width, rect_height), pygame.SRCALPHA) + pygame.draw.rect(shadow_surface, (0, 0, 0, 100), shadow_surface.get_rect(), border_radius=border_radius) + screen.blit(shadow_surface, shadow_rect.topleft) + + disclaimer_rect = pygame.Rect(rect_x, rect_y, rect_width, rect_height) + disclaimer_surface = pygame.Surface((rect_width, rect_height), pygame.SRCALPHA) + pygame.draw.rect(disclaimer_surface, (30, 30, 30, 220), disclaimer_surface.get_rect(), border_radius=border_radius) + screen.blit(disclaimer_surface, disclaimer_rect.topleft) + + pygame.draw.rect(screen, (255, 255, 255), disclaimer_rect, border_width, border_radius=border_radius) + + for i, line in enumerate(disclaimer_lines): + text_surface = config.font.render(line, True, (255, 255, 255)) + text_rect = text_surface.get_rect(center=( + config.screen_width // 2, + rect_y + padding_vertical + (i + 0.5) * line_height - padding_between // 2 + )) + screen.blit(text_surface, text_rect) + + loading_y = rect_y + rect_height + 100 + text = config.font.render(f"{config.current_loading_system}", True, (255, 255, 255)) + text_rect = text.get_rect(center=(config.screen_width // 2, loading_y)) + screen.blit(text, text_rect) + + progress_text = config.font.render(f"Progression : {int(config.loading_progress)}%", True, (255, 255, 255)) + progress_rect = progress_text.get_rect(center=(config.screen_width // 2, loading_y + 50)) + screen.blit(progress_text, progress_rect) + + bar_width = 400 + bar_height = 40 + progress_width = (bar_width * config.loading_progress) / 100 + pygame.draw.rect(screen, (100, 100, 100), (config.screen_width // 2 - bar_width // 2, loading_y + 100, bar_width, bar_height)) + pygame.draw.rect(screen, (0, 255, 0), (config.screen_width // 2 - bar_width // 2, loading_y + 100, progress_width, bar_height)) + +def draw_error_screen(screen): + """Affiche l’écran d’erreur.""" + error_font = pygame.font.SysFont("arial", 28) + text = error_font.render(config.error_message, True, (255, 0, 0)) + text_rect = text.get_rect(center=(config.screen_width // 2, config.screen_height // 2)) + screen.blit(text, text_rect) + retry_text = config.font.render(f"{config.controls_config.get('confirm', {}).get('display', 'Entrée/A')} : retenter, {config.controls_config.get('cancel', {}).get('display', 'Échap/B')} : quitter", True, (255, 255, 255)) + retry_rect = retry_text.get_rect(center=(config.screen_width // 2, config.screen_height // 2 + 100)) + screen.blit(retry_text, retry_rect) + +def draw_platform_grid(screen): + """Affiche la grille des plateformes.""" + margin_left = 50 + margin_right = 50 + margin_top = 120 + margin_bottom = 70 + num_cols = 3 + num_rows = 3 + systems_per_page = num_cols * num_rows + + available_width = config.screen_width - margin_left - margin_right + available_height = config.screen_height - margin_top - margin_bottom + + col_width = available_width // num_cols + row_height = available_height // num_rows + + x_positions = [margin_left + col_width * i + col_width // 2 for i in range(num_cols)] + y_positions = [margin_top + row_height * i + row_height // 2 for i in range(num_rows)] + + start_idx = config.current_page * systems_per_page + logger.debug(f"Page {config.current_page}, start_idx: {start_idx}, total_platforms: {len(config.platforms)}") + + for idx in range(start_idx, start_idx + systems_per_page): + if idx >= len(config.platforms): + break + grid_idx = idx - start_idx + row = grid_idx // num_cols + col = grid_idx % num_cols + x = x_positions[col] + y = y_positions[row] + scale = 1.5 if idx == config.selected_platform else 1.0 + platform_dict = config.platform_dicts[idx] + image = load_system_image(platform_dict) + if image: + # Calculer la taille en respectant le ratio d'origine + orig_width, orig_height = image.get_width(), image.get_height() + max_size = int(min(col_width, row_height) * scale * 0.9) + ratio = min(max_size / orig_width, max_size / orig_height) + new_width = int(orig_width * ratio) + new_height = int(orig_height * ratio) + image = pygame.transform.smoothscale(image, (new_width, new_height)) + image_rect = image.get_rect(center=(x, y)) + + if idx == config.selected_platform: + neon_color = (0, 255, 255) + border_radius = 24 + padding = 24 + rect_width = image_rect.width + 2 * padding + rect_height = image_rect.height + 2 * padding + neon_surface = pygame.Surface((rect_width, rect_height), pygame.SRCALPHA) + pygame.draw.rect( + neon_surface, + neon_color + (60,), + neon_surface.get_rect(), + width=1, + border_radius=border_radius + 8 + ) + pygame.draw.rect( + neon_surface, + neon_color + (180,), + neon_surface.get_rect().inflate(-8, -8), + width=2, + border_radius=border_radius + ) + screen.blit(neon_surface, (image_rect.left - padding, image_rect.top - padding), special_flags=pygame.BLEND_RGBA_ADD) + + screen.blit(image, image_rect) + +def draw_virtual_keyboard(screen): + """Affiche un clavier virtuel pour la saisie dans search_mode, centré verticalement.""" + keyboard_layout = [ + ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], + ['A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + ['Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M'], + ['W', 'X', 'C', 'V', 'B', 'N'] + ] + key_width = 60 + key_height = 60 + key_spacing = 10 + keyboard_width = len(keyboard_layout[0]) * (key_width + key_spacing) - key_spacing + keyboard_height = len(keyboard_layout) * (key_height + key_spacing) - key_spacing + start_x = (config.screen_width - keyboard_width) // 2 + search_bottom_y = 120 + (config.search_font.get_height() + 40) // 2 + controls_y = config.screen_height - 20 + available_height = controls_y - search_bottom_y + start_y = search_bottom_y + (available_height - keyboard_height - 40) // 2 + + keyboard_rect = pygame.Rect(start_x - 20, start_y - 20, keyboard_width + 40, keyboard_height + 40) + pygame.draw.rect(screen, (50, 50, 50, 200), keyboard_rect, border_radius=10) + pygame.draw.rect(screen, (255, 255, 255), keyboard_rect, 2, border_radius=10) + + for row_idx, row in enumerate(keyboard_layout): + for col_idx, key in enumerate(row): + x = start_x + col_idx * (key_width + key_spacing) + y = start_y + row_idx * (key_height + key_spacing) + key_rect = pygame.Rect(x, y, key_width, key_height) + if (row_idx, col_idx) == config.selected_key: + pygame.draw.rect(screen, (0, 150, 255, 150), key_rect, border_radius=5) + else: + pygame.draw.rect(screen, (80, 80, 80, 255), key_rect, border_radius=5) + pygame.draw.rect(screen, (255, 255, 255), key_rect, 1, border_radius=5) + text = config.font.render(key, True, (255, 255, 255)) + text_rect = text.get_rect(center=key_rect.center) + screen.blit(text, text_rect) + +def draw_progress_screen(screen): + """Affiche l'écran de progression des téléchargements avec taille en Mo, et un message spécifique pour la conversion ISO.""" + logger.debug("Début de draw_progress_screen") + + if not config.download_tasks: + logger.debug("Aucune tâche de téléchargement active") + return + + task = list(config.download_tasks.keys())[0] + game_name = config.download_tasks[task][2] + url = config.download_tasks[task][1] + progress = config.download_progress.get(url, {"downloaded_size": 0, "total_size": 0, "status": "Téléchargement", "progress_percent": 0}) + status = progress.get("status", "Téléchargement") + downloaded_size = progress["downloaded_size"] + total_size = progress["total_size"] + progress_percent = progress["progress_percent"] + logger.debug(f"Progression : game_name={game_name}, url={url}, status={status}, progress_percent={progress_percent}, downloaded_size={downloaded_size}, total_size={total_size}") + + overlay = pygame.Surface((config.screen_width, config.screen_height), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + screen.blit(overlay, (0, 0)) + + if status == "Converting ISO": + title_text = f"Converting : {truncate_text_end(game_name, config.font, config.screen_width - 200)}" + else: + title_text = f"{status} : {truncate_text_end(game_name, config.font, config.screen_width - 200)}" + title_render = config.font.render(title_text, True, (255, 255, 255)) + title_rect = title_render.get_rect(center=(config.screen_width // 2, config.screen_height // 2 - 100)) + pygame.draw.rect(screen, (50, 50, 50, 200), title_rect.inflate(40, 20)) + pygame.draw.rect(screen, (255, 255, 255), title_rect.inflate(40, 20), 2) + screen.blit(title_render, title_rect) + logger.debug(f"Titre affiché : texte={title_text}, position={title_rect}, taille={title_render.get_size()}") + + if status == "Converting ISO": + conversion_text = config.font.render("Conversion de l'ISO en dossier .ps3 en cours...", True, (255, 255, 255)) + conversion_rect = conversion_text.get_rect(center=(config.screen_width // 2, config.screen_height // 2)) + pygame.draw.rect(screen, (50, 50, 50, 200), conversion_rect.inflate(40, 20)) + pygame.draw.rect(screen, (255, 255, 255), conversion_rect.inflate(40, 20), 2) + screen.blit(conversion_text, conversion_rect) + logger.debug(f"Message de conversion affiché : position={conversion_rect}, taille={conversion_text.get_size()}") + else: + bar_width = config.screen_width // 2 + bar_height = 30 + bar_x = (config.screen_width - bar_width) // 2 + bar_y = config.screen_height // 2 + progress_width = 0 + pygame.draw.rect(screen, (100, 100, 100), (bar_x, bar_y, bar_width, bar_height)) + if total_size > 0: + progress_width = int(bar_width * (progress_percent / 100)) + pygame.draw.rect(screen, (0, 150, 255), (bar_x, bar_y, progress_width, bar_height)) + pygame.draw.rect(screen, (255, 255, 255), (bar_x, bar_y, bar_width, bar_height), 2) + logger.debug(f"Barre de progression affichée : position=({bar_x}, {bar_y}), taille=({bar_width}, {bar_height}), progress_width={progress_width}") + + downloaded_mb = downloaded_size / (1024 * 1024) + total_mb = total_size / (1024 * 1024) + size_text = f"{downloaded_mb:.1f} Mo / {total_mb:.1f} Mo" + percent_text = f"{int(progress_percent)}% {size_text}" + percent_render = config.font.render(percent_text, True, (255, 255, 255)) + text_y = bar_y + bar_height // 2 + config.font.get_height() + 20 + percent_rect = percent_render.get_rect(center=(config.screen_width // 2, text_y)) + pygame.draw.rect(screen, (50, 50, 50, 200), percent_rect.inflate(20, 10)) + pygame.draw.rect(screen, (255, 255, 255), percent_rect.inflate(20, 10), 2) + screen.blit(percent_render, percent_rect) + logger.debug(f"Texte de progression affiché : texte={percent_text}, position={percent_rect}, taille={percent_render.get_size()}") + +def draw_scrollbar(screen): + """Affiche la barre de défilement à droite de l’écran.""" + if len(config.filtered_games) <= config.visible_games: + return + + game_area_height = config.screen_height - 150 + scrollbar_height = game_area_height * (config.visible_games / len(config.filtered_games)) + scrollbar_y = 120 + (game_area_height - scrollbar_height) * (config.scroll_offset / max(1, len(config.filtered_games) - config.visible_games)) + pygame.draw.rect(screen, (255, 255, 255), (config.screen_width - 25, scrollbar_y, 15, scrollbar_height)) + +def draw_confirm_dialog(screen): + """Affiche la boîte de dialogue de confirmation pour quitter.""" + overlay = pygame.Surface((config.screen_width, config.screen_height), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + screen.blit(overlay, (0, 0)) + + message = "Voulez-vous vraiment quitter ?" + text = config.font.render(message, True, (255, 255, 255)) + text_rect = text.get_rect(center=(config.screen_width // 2, config.screen_height // 2 - 50)) + pygame.draw.rect(screen, (50, 50, 50, 200), text_rect.inflate(40, 20)) + pygame.draw.rect(screen, (255, 255, 255), text_rect.inflate(40, 20), 2) + screen.blit(text, text_rect) + + yes_text = config.font.render("Oui", True, (255, 255, 255)) + no_text = config.font.render("Non", True, (255, 255, 255)) + yes_rect = yes_text.get_rect(center=(config.screen_width // 2 - 100, config.screen_height // 2 + 50)) + no_rect = no_text.get_rect(center=(config.screen_width // 2 + 100, config.screen_height // 2 + 50)) + + if config.confirm_selection == 1: + pygame.draw.rect(screen, (0, 150, 255, 150), yes_rect.inflate(40, 20)) + else: + pygame.draw.rect(screen, (0, 150, 255, 150), no_rect.inflate(40, 20)) + + pygame.draw.rect(screen, (255, 255, 255), yes_rect.inflate(40, 20), 2) + pygame.draw.rect(screen, (255, 255, 255), no_rect.inflate(40, 20), 2) + screen.blit(yes_text, yes_rect) + screen.blit(no_text, no_rect) + +def draw_popup_message(screen, message, is_error): + """Affiche une popup avec un message de résultat.""" + overlay = pygame.Surface((config.screen_width, config.screen_height), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + screen.blit(overlay, (0, 0)) + + text = config.font.render(message, True, (255, 0, 0) if is_error else (0, 255, 0)) + text_rect = text.get_rect(center=(config.screen_width // 2, config.screen_height // 2)) + pygame.draw.rect(screen, (50, 50, 50, 200), text_rect.inflate(40, 20)) + pygame.draw.rect(screen, (255, 255, 255), text_rect.inflate(40, 20), 2) + screen.blit(text, text_rect) + +def draw_extension_warning(screen): + """Affiche un avertissement pour une extension non reconnue ou un fichier ZIP.""" + #logger.debug("Début de draw_extension_warning") + + if not config.pending_download: + #logger.error("config.pending_download est None ou vide dans extension_warning") + message = "Erreur : Aucun téléchargement en attente." + is_zip = False + game_name = "Inconnu" + else: + url, platform, game_name, is_zip_non_supported = config.pending_download + # logger.debug(f"config.pending_download: url={url}, platform={platform}, game_name={game_name}, is_zip_non_supported={is_zip_non_supported}") + is_zip = is_zip_non_supported + if not game_name: + game_name = "Inconnu" + logger.warning("game_name vide, utilisation de 'Inconnu'") + + if is_zip: + message = f"Le fichier '{game_name}' est une archive et Batocera ne prend pas en charge les archives pour ce système. L'extraction automatique du fichier aura lieu après le téléchargement, continuer ?" + else: + message = f"L'extension du fichier '{game_name}' n'est pas supportée par Batocera d'après le fichier info.txt. Voulez-vous continuer ?" + + max_width = config.screen_width - 80 + lines = wrap_text(message, config.font, max_width) + #logger.debug(f"Lignes générées : {lines}") + + try: + # Calcul de la hauteur de ligne + line_height = config.font.get_height() + 5 + # Hauteur pour les lignes de texte + text_height = len(lines) * line_height + # Hauteur pour les boutons (1 ligne + marges) + button_height = line_height + 20 + # Marges en haut et en bas + margin_top_bottom = 20 + # Hauteur totale du rectangle + rect_height = text_height + button_height + 2 * margin_top_bottom + # Largeur du rectangle + max_text_width = max([config.font.size(line)[0] for line in lines], default=300) + rect_width = max_text_width + 40 + # Position du rectangle + rect_x = (config.screen_width - rect_width) // 2 + rect_y = (config.screen_height - rect_height) // 2 + + # Dessiner le rectangle de fond + pygame.draw.rect(screen, (50, 50, 50, 200), (rect_x, rect_y, rect_width, rect_height), border_radius=10) + pygame.draw.rect(screen, (255, 255, 255), (rect_x, rect_y, rect_width, rect_height), 2, border_radius=10) + + # Afficher les lignes de texte + text_surfaces = [config.font.render(line, True, (255, 255, 255)) for line in lines] + text_rects = [ + surface.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2)) + for i, surface in enumerate(text_surfaces) + ] + for surface, rect in zip(text_surfaces, text_rects): + screen.blit(surface, rect) + #logger.debug(f"Lignes affichées : {[(rect.center, surface.get_size()) for rect, surface in zip(text_rects, text_surfaces)]}") + + # Afficher les boutons Oui/Non + yes_text = "[Oui]" if config.extension_confirm_selection == 1 else "Oui" + no_text = "[Non]" if config.extension_confirm_selection == 0 else "Non" + yes_surface = config.font.render(yes_text, True, (0, 150, 255) if config.extension_confirm_selection == 1 else (255, 255, 255)) + no_surface = config.font.render(no_text, True, (0, 150, 255) if config.extension_confirm_selection == 0 else (255, 255, 255)) + + button_y = rect_y + text_height + margin_top_bottom + line_height // 2 + yes_rect = yes_surface.get_rect(center=(config.screen_width // 2 - 100, button_y)) + no_rect = no_surface.get_rect(center=(config.screen_width // 2 + 100, button_y)) + + screen.blit(yes_surface, yes_rect) + screen.blit(no_surface, no_rect) + #logger.debug(f"Boutons affichés : Oui={yes_rect}, Non={no_rect}, selection={config.extension_confirm_selection}") + + except Exception as e: + logger.error(f"Erreur lors du rendu de extension_warning : {str(e)}") + error_message = "Erreur d'affichage de l'avertissement." + error_surface = config.font.render(error_message, True, (255, 0, 0)) + error_rect = error_surface.get_rect(center=(config.screen_width // 2, config.screen_height // 2)) + pygame.draw.rect(screen, (50, 50, 50, 200), error_rect.inflate(40, 20), border_radius=10) + pygame.draw.rect(screen, (255, 255, 255), error_rect.inflate(40, 20), 2, border_radius=10) + screen.blit(error_surface, error_rect) + +def draw_controls(screen, menu_state): + """Affiche les contrôles sur une seule ligne en bas de l’écran pour tous les états du menu.""" + start_button = config.controls_config.get('start', {}).get('display', 'START') + control_text = f"{start_button} : Menu - Controls + max_width = config.screen_width - 40 + control_text = truncate_text_end(control_text, config.font, max_width) + text_surface = config.font.render(control_text, True, (255, 255, 255)) + text_width, text_height = text_surface.get_size() + + rect_width = text_width + 40 + rect_height = text_height + 20 + rect_x = (config.screen_width - rect_width) // 2 + rect_y = config.screen_height - rect_height - 20 + + pygame.draw.rect(screen, (50, 50, 50, 200), (rect_x, rect_y, rect_width, rect_height), border_radius=10) + pygame.draw.rect(screen, (255, 255, 255), (rect_x, rect_y, rect_width, rect_height), 2, border_radius=10) + + text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + rect_height // 2)) + screen.blit(text_surface, text_rect) + +def draw_validation_transition(screen, platform_index): + """Affiche une animation de transition pour la sélection d’une plateforme.""" + platform_dict = config.platform_dicts[platform_index] # Use platform dictionary + image = load_system_image(platform_dict) + if not image: + return + orig_width, orig_height = image.get_width(), image.get_height() + base_size = 150 + start_time = pygame.time.get_ticks() + duration = 500 + while pygame.time.get_ticks() - start_time < duration: + draw_gradient(screen, (28, 37, 38), (47, 59, 61)) + elapsed = pygame.time.get_ticks() - start_time + scale = 2.0 + (2.0 * elapsed / duration) if elapsed < duration / 2 else 3.0 - (2.0 * elapsed / duration) + new_width = int(base_size * scale) + new_height = int(base_size * scale) + scaled_image = pygame.transform.smoothscale(image, (new_width, new_height)) + image_rect = scaled_image.get_rect(center=(config.screen_width // 2, config.screen_height // 2)) + screen.blit(scaled_image, image_rect) + pygame.display.flip() + pygame.time.wait(10) + +def draw_pause_menu(screen, selected_option): + """Dessine le menu pause avec les options Aide, Configurer contrôles, Quitter.""" + overlay = pygame.Surface((config.screen_width, config.screen_height), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + screen.blit(overlay, (0, 0)) + + options = [ + "Controls", + "Remap controls", + "Quit" + ] + + menu_width = 400 + menu_height = len(options) * 60 + 40 + menu_x = (config.screen_width - menu_width) // 2 + menu_y = (config.screen_height - menu_height) // 2 + + pygame.draw.rect(screen, (50, 50, 50, 200), (menu_x, menu_y, menu_width, menu_height)) + pygame.draw.rect(screen, (255, 255, 255), (menu_x, menu_y, menu_width, menu_height), 2) + + for i, option in enumerate(options): + color = (0, 150, 255) if i == selected_option else (255, 255, 255) + text_surface = config.font.render(option, True, color) + text_rect = text_surface.get_rect(center=(config.screen_width // 2, menu_y + 30 + i * 60)) + screen.blit(text_surface, text_rect) + +def get_control_display(action, default): + """Récupère le nom d'affichage d'une action depuis controls_config.""" + return config.controls_config.get(action, {}).get('display', default) + +def draw_controls_help(screen, previous_state): + """Affiche la liste des contrôles pour l'état précédent du menu.""" + # Dictionnaire des contrôles par état + state_controls = { + "error": [ + f"{get_control_display('confirm', 'A')} : Retenter", + f"{get_control_display('cancel', 'Échap/B')} : Quitter" + ], + "platform": [ + f"{get_control_display('confirm', 'Entrée/A')} : Sélectionner", + f"{get_control_display('cancel', 'Échap/B')} : Quitter", + f"{get_control_display('start', 'Start')} : Menu", + *( [f"{get_control_display('progress', 'X')} : Progression"] if config.download_tasks else []) + ], + "game": [ + f"{get_control_display('confirm', 'Entrée/A')} : {'Valider' if config.search_mode else 'Télécharger'}", + f"{get_control_display('cancel', 'Échap/B')} : {'Annuler' if config.search_mode else 'Retour'}", + *( [ + f"{get_control_display('delete', 'Retour Arrière')} : Supprimer" if config.controls_config.get('delete', {}) else [], + f"{get_control_display('space', 'Espace')} : Espace" if config.controls_config.get('space', {}) else [] + ] if config.search_mode and config.is_non_pc else []), + *( [ + "Saisir texte : Filtrer" if config.search_mode else + f"{get_control_display('up', 'Flèche Haut')} / {get_control_display('down', 'Flèche Bas')} : Naviguer", + f"{get_control_display('page_up', 'Q/LB')} / {get_control_display('page_down', 'E/RB')} : Page", + f"{get_control_display('filter', 'Select')} : Filtrer" + ] if not config.is_non_pc or not config.search_mode else []), + f"{get_control_display('start', 'Start')} : Menu", + *( [f"{get_control_display('progress', 'X')} : Progression"] if config.download_tasks and not config.search_mode else []) + ], + "download_progress": [ + f"{get_control_display('cancel', 'Échap/B')} : Annuler le téléchargement", + f"{get_control_display('progress', 'X')} : Arrière plan", + f"{get_control_display('start', 'Start')} : Menu" + ], + "download_result": [ + f"{get_control_display('confirm', 'Entrée/A')} : Retour" + ], + "confirm_exit": [ + f"{get_control_display('confirm', 'Entrée/A')} : Confirmer" + ], + "extension_warning": [ + f"{get_control_display('confirm', 'Entrée/A')} : Confirmer" + ] + } + + # Récupérer les contrôles pour l'état donné + controls = state_controls.get(previous_state, []) + + # Créer un overlay semi-transparent + overlay = pygame.Surface((config.screen_width, config.screen_height), pygame.SRCALPHA) + overlay.fill((0, 0, 0, 180)) + screen.blit(overlay, (0, 0)) + + # Envelopper les textes pour respecter la largeur maximale + max_width = config.screen_width - 80 + wrapped_controls = [] + current_line = "" + for control in controls: + test_line = f"{current_line} | {control}" if current_line else control + if config.font.size(test_line)[0] <= max_width: + current_line = test_line + else: + wrapped_controls.append(current_line) + current_line = control + if current_line: + wrapped_controls.append(current_line) + + # Calculer les dimensions de la popup + line_height = config.font.get_height() + 10 + popup_width = max_width + 40 + popup_height = len(wrapped_controls) * line_height + 60 + popup_x = (config.screen_width - popup_width) // 2 + popup_y = (config.screen_height - popup_height) // 2 + + # Dessiner la popup + pygame.draw.rect(screen, (50, 50, 50, 200), (popup_x, popup_y, popup_width, popup_height), border_radius=16) + pygame.draw.rect(screen, (255, 255, 255), (popup_x, popup_y, popup_width, popup_height), 2, border_radius=16) + + # Afficher les textes + for i, line in enumerate(wrapped_controls): + text = config.font.render(line, True, (255, 255, 255)) + text_rect = text.get_rect(center=(config.screen_width // 2, popup_y + 40 + i * line_height)) + screen.blit(text, text_rect) \ No newline at end of file diff --git a/network.py b/network.py new file mode 100644 index 0000000..d8fcb9b --- /dev/null +++ b/network.py @@ -0,0 +1,381 @@ +import requests +import subprocess +import re +import os +import threading +import pygame +import zipfile +import json +from urllib.parse import urljoin, unquote +import asyncio +import config +from utils import sanitize_filename +import logging + +logger = logging.getLogger(__name__) + +JSON_EXTENSIONS = "/userdata/roms/ports/RGSX/rom_extensions.json" + +def test_internet(): + logger.debug("Test de connexion Internet") + try: + result = subprocess.run(['ping', '-c', '4', '8.8.8.8'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + logger.debug("Connexion Internet OK") + return True + else: + logger.debug("Échec ping 8.8.8.8") + return False + except Exception as e: + logger.debug(f"Erreur test Internet: {str(e)}") + return False + +def load_extensions_json(): + """Charge le fichier JSON contenant les extensions supportées.""" + try: + with open(JSON_EXTENSIONS, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"Erreur lors de la lecture de {JSON_EXTENSIONS}: {e}") + return [] + +def is_extension_supported(filename, platform, extensions_data): + """Vérifie si l'extension du fichier est supportée pour la plateforme donnée.""" + extension = os.path.splitext(filename)[1].lower() + dest_dir = None + for platform_dict in config.platform_dicts: + if platform_dict["platform"] == platform: + dest_dir = platform_dict.get("folder") + break + if not dest_dir: + logger.warning(f"Aucun dossier 'folder' trouvé pour la plateforme {platform}") + dest_dir = os.path.join("/userdata/roms", platform) + for system in extensions_data: + if system["folder"] == dest_dir: + return extension in system["extensions"] + logger.warning(f"Aucun système trouvé pour le dossier {dest_dir}") + return False + +def extract_zip(zip_path, dest_dir, url): + """Extrait le contenu du fichier ZIP dans le dossier cible avec un suivi progressif de la progression.""" + try: + lock = threading.Lock() + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + total_size = sum(info.file_size for info in zip_ref.infolist() if not info.is_dir()) + logger.info(f"Taille totale à extraire: {total_size} octets") + if total_size == 0: + logger.warning("ZIP vide ou ne contenant que des dossiers") + return True, "ZIP vide extrait avec succès" + + extracted_size = 0 + os.makedirs(dest_dir, exist_ok=True) + chunk_size = 8192 + for info in zip_ref.infolist(): + if info.is_dir(): + continue + file_path = os.path.join(dest_dir, info.filename) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with zip_ref.open(info) as source, open(file_path, 'wb') as dest: + file_size = info.file_size + file_extracted = 0 + while True: + chunk = source.read(chunk_size) + if not chunk: + break + dest.write(chunk) + file_extracted += len(chunk) + extracted_size += len(chunk) + with lock: + config.download_progress[url]["downloaded_size"] = extracted_size + config.download_progress[url]["total_size"] = total_size + config.download_progress[url]["status"] = "Extracting" + config.download_progress[url]["progress_percent"] = (extracted_size / total_size * 100) if total_size > 0 else 0 + config.needs_redraw = True # Forcer le redraw + logger.debug(f"Extraction {info.filename}, chunk: {len(chunk)}, file_extracted: {file_extracted}/{file_size}, total_extracted: {extracted_size}/{total_size}, progression: {(extracted_size/total_size*100):.1f}%") + os.chmod(file_path, 0o644) + + for root, dirs, _ in os.walk(dest_dir): + for dir_name in dirs: + os.chmod(os.path.join(root, dir_name), 0o755) + + os.remove(zip_path) + logger.info(f"Fichier ZIP {zip_path} extrait dans {dest_dir} et supprimé") + return True, "ZIP extrait avec succès" + except Exception as e: + logger.error(f"Erreur lors de l'extraction de {zip_path}: {e}") + return False, str(e) + +def extract_rar(rar_path, dest_dir, url): + """Extrait le contenu du fichier RAR dans le dossier cible, préservant la structure des dossiers.""" + try: + lock = threading.Lock() + os.makedirs(dest_dir, exist_ok=True) + + result = subprocess.run(['unrar'], capture_output=True, text=True) + if result.returncode not in [0, 1]: + logger.error("Commande unrar non disponible") + return False, "Commande unrar non disponible" + + result = subprocess.run(['unrar', 'l', '-v', rar_path], capture_output=True, text=True) + if result.returncode != 0: + error_msg = result.stderr.strip() + logger.error(f"Erreur lors de la liste des fichiers RAR: {error_msg}") + return False, f"Échec de la liste des fichiers RAR: {error_msg}" + + logger.debug(f"Sortie brute de 'unrar l -v {rar_path}':\n{result.stdout}") + + total_size = 0 + files_to_extract = [] + root_dirs = set() + lines = result.stdout.splitlines() + in_file_list = False + for line in lines: + if line.startswith("----"): + in_file_list = not in_file_list + continue + if in_file_list: + match = re.match(r'^\s*(\S+)\s+(\d+)\s+\d*\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+(.+)$', line) + if match: + attrs = match.group(1) + file_size = int(match.group(2)) + file_date = match.group(3) + file_name = match.group(4).strip() + if 'D' not in attrs: + files_to_extract.append((file_name, file_size)) + total_size += file_size + root_dir = file_name.split('/')[0] if '/' in file_name else '' + if root_dir: + root_dirs.add(root_dir) + logger.debug(f"Ligne parsée: {file_name}, taille: {file_size}, date: {file_date}") + else: + logger.debug(f"Dossier ignoré: {file_name}") + else: + logger.debug(f"Ligne ignorée (format inattendu): {line}") + + logger.info(f"Taille totale à extraire (RAR): {total_size} octets") + logger.debug(f"Fichiers à extraire: {files_to_extract}") + logger.debug(f"Dossiers racines détectés: {root_dirs}") + if total_size == 0: + logger.warning("RAR vide, ne contenant que des dossiers, ou erreur de parsing") + return False, "RAR vide ou erreur lors de la liste des fichiers" + + with lock: + config.download_progress[url]["downloaded_size"] = 0 + config.download_progress[url]["total_size"] = total_size + config.download_progress[url]["status"] = "Extracting" + config.download_progress[url]["progress_percent"] = 0 + config.needs_redraw = True # Forcer le redraw + + escaped_rar_path = rar_path.replace(" ", "\\ ") + escaped_dest_dir = dest_dir.replace(" ", "\\ ") + process = subprocess.Popen(['unrar', 'x', '-y', escaped_rar_path, escaped_dest_dir], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + stdout, stderr = process.communicate() + + if process.returncode != 0: + logger.error(f"Erreur lors de l'extraction de {rar_path}: {stderr}") + return False, f"Erreur lors de l'extraction: {stderr}" + + extracted_size = 0 + extracted_files = [] + for root, _, files in os.walk(dest_dir): + for file in files: + file_path = os.path.join(root, file) + rel_path = os.path.relpath(file_path, dest_dir).replace(os.sep, '/') + for expected_file, file_size in files_to_extract: + if rel_path == expected_file: + extracted_size += file_size + extracted_files.append(expected_file) + os.chmod(file_path, 0o644) + logger.debug(f"Fichier extrait: {expected_file}, taille: {file_size}, chemin: {file_path}") + break + + missing_files = [f for f, _ in files_to_extract if f not in extracted_files] + if missing_files: + logger.warning(f"Fichiers non extraits: {', '.join(missing_files)}") + return False, f"Fichiers non extraits: {', '.join(missing_files)}" + + with lock: + config.download_progress[url]["downloaded_size"] = extracted_size + config.download_progress[url]["total_size"] = total_size + config.download_progress[url]["status"] = "Extracting" + config.download_progress[url]["progress_percent"] = 100 if total_size > 0 else 0 + config.needs_redraw = True # Forcer le redraw + + if dest_dir == "/userdata/roms/ps3" and len(root_dirs) == 1: + root_dir = root_dirs.pop() + old_path = os.path.join(dest_dir, root_dir) + new_path = os.path.join(dest_dir, f"{root_dir}.ps3") + if os.path.isdir(old_path): + try: + os.rename(old_path, new_path) + logger.info(f"Dossier renommé: {old_path} -> {new_path}") + except Exception as e: + logger.error(f"Erreur lors du renommage de {old_path} en {new_path}: {str(e)}") + return False, f"Erreur lors du renommage du dossier: {str(e)}" + else: + logger.warning(f"Dossier racine {old_path} non trouvé après extraction") + elif dest_dir == "/userdata/roms/ps3" and len(root_dirs) > 1: + logger.warning(f"Plusieurs dossiers racines détectés dans l'archive: {root_dirs}. Aucun renommage effectué.") + + for root, dirs, _ in os.walk(dest_dir): + for dir_name in dirs: + os.chmod(os.path.join(root, dir_name), 0o755) + + os.remove(rar_path) + logger.info(f"Fichier RAR {rar_path} extrait dans {dest_dir} et supprimé") + return True, "RAR extrait avec succès" + except Exception as e: + logger.error(f"Erreur lors de l'extraction de {rar_path}: {str(e)}") + return False, str(e) + finally: + if os.path.exists(rar_path): + try: + os.remove(rar_path) + logger.info(f"Fichier RAR {rar_path} supprimé après échec de l'extraction") + except Exception as e: + logger.error(f"Erreur lors de la suppression de {rar_path}: {str(e)}") + +async def download_rom(url, platform, game_name, is_zip_non_supported=False): + logger.debug(f"Début téléchargement: {game_name} depuis {url}, is_zip_non_supported={is_zip_non_supported}") + result = [None, None] + + def download_thread(): + logger.debug(f"Thread téléchargement démarré pour {url}") + try: + dest_dir = None + for platform_dict in config.platform_dicts: + if platform_dict["platform"] == platform: + dest_dir = platform_dict.get("folder") + break + if not dest_dir: + logger.warning(f"Aucun dossier 'folder' trouvé pour la plateforme {platform}") + dest_dir = os.path.join("/userdata/roms", platform) + + logger.debug(f"Vérification répertoire destination: {dest_dir}") + os.makedirs(dest_dir, exist_ok=True) + if not os.access(dest_dir, os.W_OK): + raise PermissionError(f"Pas de permission d'écriture dans {dest_dir}") + + sanitized_name = sanitize_filename(game_name) + dest_path = os.path.join(dest_dir, f"{sanitized_name}") + logger.debug(f"Chemin destination: {dest_path}") + + lock = threading.Lock() + with lock: + config.download_progress[url] = { + "downloaded_size": 0, + "total_size": 0, + "status": "Téléchargement", + "progress_percent": 0, + "game_name": game_name + } + config.needs_redraw = True # Forcer le redraw + logger.debug(f"Progression initialisée pour {url}") + + headers = {'User-Agent': 'Mozilla/5.0'} + logger.debug(f"Envoi requête GET à {url}") + response = requests.get(url, stream=True, headers=headers, timeout=30) + logger.debug(f"Réponse reçue, status: {response.status_code}") + response.raise_for_status() + + total_size = int(response.headers.get('content-length', 0)) + logger.debug(f"Taille totale: {total_size} octets") + with lock: + config.download_progress[url]["total_size"] = total_size + config.needs_redraw = True # Forcer le redraw + + downloaded = 0 + with open(dest_path, 'wb') as f: + logger.debug(f"Ouverture fichier: {dest_path}") + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + with lock: + config.download_progress[url]["downloaded_size"] = downloaded + config.download_progress[url]["status"] = "Téléchargement" + config.download_progress[url]["progress_percent"] = (downloaded / total_size * 100) if total_size > 0 else 0 + config.needs_redraw = True # Forcer le redraw + logger.debug(f"Progression: {downloaded}/{total_size} octets, {config.download_progress[url]['progress_percent']:.1f}%") + + if is_zip_non_supported: + with lock: + config.download_progress[url]["downloaded_size"] = 0 + config.download_progress[url]["total_size"] = 0 + config.download_progress[url]["status"] = "Extracting" + config.download_progress[url]["progress_percent"] = 0 + config.needs_redraw = True # Forcer le redraw + extension = os.path.splitext(dest_path)[1].lower() + if extension == ".zip": + success, msg = extract_zip(dest_path, dest_dir, url) + elif extension == ".rar": + success, msg = extract_rar(dest_path, dest_dir, url) + else: + raise Exception(f"Type d'archive non supporté: {extension}") + if not success: + raise Exception(f"Échec de l'extraction de l'archive: {msg}") + result[0] = True + result[1] = f"Téléchargé et extrait : {game_name}" + else: + os.chmod(dest_path, 0o644) + logger.debug(f"Téléchargement terminé: {dest_path}") + result[0] = True + result[1] = f"Téléchargé : {game_name}" + except Exception as e: + logger.error(f"Erreur téléchargement {url}: {str(e)}") + if url in config.download_progress: + with lock: + del config.download_progress[url] + if os.path.exists(dest_path): + os.remove(dest_path) + result[0] = False + result[1] = str(e) + finally: + logger.debug(f"Thread téléchargement terminé pour {url}") + with lock: + config.needs_redraw = True # Forcer le redraw + + thread = threading.Thread(target=download_thread) + logger.debug(f"Démarrage thread pour {url}") + thread.start() + while thread.is_alive(): + pygame.event.pump() + await asyncio.sleep(0.1) + thread.join() + logger.debug(f"Thread rejoint pour {url}") + + with threading.Lock(): + config.download_result_message = result[1] + config.download_result_error = not result[0] + config.download_result_start_time = pygame.time.get_ticks() + config.menu_state = "download_result" + config.needs_redraw = True # Forcer le redraw + logger.debug(f"Transition vers download_result, message={result[1]}, erreur={not result[0]}") + return result[0], result[1] + +def check_extension_before_download(url, platform, game_name): + """Vérifie l'extension avant de lancer le téléchargement.""" + try: + sanitized_name = sanitize_filename(game_name) + extensions_data = load_extensions_json() + if not extensions_data: + logger.error(f"Fichier {JSON_EXTENSIONS} vide ou introuvable") + return False, "Fichier de configuration des extensions introuvable", False + + is_supported = is_extension_supported(sanitized_name, platform, extensions_data) + extension = os.path.splitext(sanitized_name)[1].lower() + is_archive = extension in (".zip", ".rar") + + if is_supported: + logger.debug(f"L'extension de {sanitized_name} est supportée pour {platform}") + return True, "", False + else: + if is_archive: + logger.debug(f"Fichier {extension.upper()} détecté pour {sanitized_name}, extraction automatique prévue") + return False, f"Fichiers {extension.upper()} non supportés par cette plateforme, extraction automatique après le téléchargement.", True + logger.debug(f"L'extension de {sanitized_name} n'est pas supportée pour {platform}") + return False, f"L'extension de {sanitized_name} n'est pas supportée pour {platform}", False + except Exception as e: + logger.error(f"Erreur vérification extension {url}: {str(e)}") + return False, str(e), False \ No newline at end of file diff --git a/update_gamelist.py b/update_gamelist.py new file mode 100644 index 0000000..8625aab --- /dev/null +++ b/update_gamelist.py @@ -0,0 +1,78 @@ +import os +import xml.dom.minidom +import xml.etree.ElementTree as ET +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +GAMELIST_FILE = "/userdata/roms/ports/gamelist.xml" +RGSX_ENTRY = { + "path": "./RGSX/RGSX.sh", + "name": "RGSX", + "desc": "Retro Games Sets X - Games Downloader", + "image": "./images/RGSX.png", + "marquee": "./images/RGSX.png", + "thumbnail": "./images/RGSX.png", + "fanart": "./images/RGSX.png", + "rating": "1", + "releasedate": "20250620T165718", + "developer": "RetroGameSets.fr", + "genre": "Compilation, Various / Utilities", + "playcount": "251", + "lastplayed": "20250621T234656", + "gametime": "30830", + "lang": "fr" +} + +def update_gamelist(): + try: + # Si le fichier n'existe pas, est vide ou non valide, créer une nouvelle structure + if not os.path.exists(GAMELIST_FILE) or os.path.getsize(GAMELIST_FILE) == 0: + logger.info(f"Création de {GAMELIST_FILE}") + root = ET.Element("gameList") + else: + try: + logger.info(f"Lecture de {GAMELIST_FILE}") + tree = ET.parse(GAMELIST_FILE) + root = tree.getroot() + if root.tag != "gameList": + logger.info(f"{GAMELIST_FILE} n'a pas de balise , création d'une nouvelle structure") + root = ET.Element("gameList") + except ET.ParseError: + logger.info(f"{GAMELIST_FILE} est invalide, création d'une nouvelle structure") + root = ET.Element("gameList") + + # Supprimer l'ancienne entrée RGSX + for game in root.findall("game"): + path = game.find("path") + if path is not None and path.text == "./RGSX/RGSX.sh": + root.remove(game) + logger.info("Ancienne entrée RGSX supprimée") + + # Ajouter la nouvelle entrée + game_elem = ET.SubElement(root, "game") + for key, value in RGSX_ENTRY.items(): + elem = ET.SubElement(game_elem, key) + elem.text = value + logger.info("Nouvelle entrée RGSX ajoutée") + + # Générer le XML avec minidom pour une indentation correcte + rough_string = '\n' + ET.tostring(root, encoding='unicode') + parsed = xml.dom.minidom.parseString(rough_string) + pretty_xml = parsed.toprettyxml(indent="\t", encoding='utf-8').decode('utf-8') + # Supprimer les lignes vides inutiles générées par minidom + pretty_xml = '\n'.join(line for line in pretty_xml.split('\n') if line.strip()) + with open(GAMELIST_FILE, 'w', encoding='utf-8') as f: + f.write(pretty_xml) + logger.info(f"{GAMELIST_FILE} mis à jour avec succès") + + # Définir les permissions + os.chmod(GAMELIST_FILE, 0o644) + + except Exception as e: + logger.error(f"Erreur lors de la mise à jour de {GAMELIST_FILE}: {e}") + raise + +if __name__ == "__main__": + update_gamelist() \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..3514985 --- /dev/null +++ b/utils.py @@ -0,0 +1,112 @@ +import pygame +import re +import json +import os +import config +import logging + +logger = logging.getLogger(__name__) + +def create_placeholder(width=400): + """Crée une image de substitution pour les jeux sans vignette.""" + logger.debug(f"Création placeholder: largeur={width}") + if config.font is None: + # Police de secours si config.font n’est pas initialisé + fallback_font = pygame.font.SysFont("arial", 24) + text = fallback_font.render("No Image", True, (255, 255, 255)) + else: + text = config.font.render("No Image", True, (255, 255, 255)) + + height = int(150 * (width / 200)) + placeholder = pygame.Surface((width, height)) + placeholder.fill((50, 50, 50)) + text_rect = text.get_rect(center=(width // 2, height // 2)) + placeholder.blit(text, text_rect) + return placeholder + +def truncate_text_middle(text, font, max_width): + """Tronque le texte en insérant '...' au milieu.""" + text_width = font.size(text)[0] + if text_width <= max_width: + return text + ellipsis = "..." + ellipsis_width = font.size(ellipsis)[0] + max_text_width = max_width - ellipsis_width + while text_width > max_text_width and len(text) > 0: + text = text[:-1] + text_width = font.size(text)[0] + mid = len(text) // 2 + return text[:mid] + ellipsis + text[mid:] + +def truncate_text_end(text, font, max_width): + """Tronque le texte à la fin pour qu'il tienne dans max_width avec la police donnée.""" + if not isinstance(text, str): + logger.error(f"Texte non valide: {text}") + return "" + if not isinstance(font, pygame.font.Font): + logger.error("Police non valide dans truncate_text_end") + return text # Retourne le texte brut si la police est invalide + + try: + if font.size(text)[0] <= max_width: + return text + + truncated = text + while len(truncated) > 0 and font.size(truncated + "...")[0] > max_width: + truncated = truncated[:-1] + return truncated + "..." if len(truncated) < len(text) else text + except Exception as e: + logger.error(f"Erreur lors du rendu du texte '{text}': {str(e)}") + return text # Retourne le texte brut en cas d'erreur + +def sanitize_filename(name): + """Sanitise les noms de fichiers en remplaçant les caractères interdits.""" + return re.sub(r'[<>:"/\/\\|?*]', '_', name).strip() + +def wrap_text(text, font, max_width): + """Divise le texte en lignes pour respecter la largeur maximale.""" + words = text.split(' ') + lines = [] + current_line = '' + + for word in words: + # Tester si ajouter le mot dépasse la largeur + test_line = current_line + (' ' if current_line else '') + word + test_surface = font.render(test_line, True, (255, 255, 255)) + if test_surface.get_width() <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + + if current_line: + lines.append(current_line) + + return lines + +def load_system_image(platform_dict): + """Charge une image système depuis le chemin spécifié dans system_image.""" + image_path = platform_dict.get("system_image") + platform_name = platform_dict.get("platform", "unknown") + #logger.debug(f"Chargement de l'image système pour {platform_name} depuis {image_path}") + try: + if not os.path.exists(image_path): + logger.error(f"Image introuvable pour {platform_name} à {image_path}") + return None + return pygame.image.load(image_path).convert_alpha() + except Exception as e: + logger.error(f"Erreur lors du chargement de l'image pour {platform_name} : {str(e)}") + return None + +def load_games(platform_id): + """Charge les jeux pour une plateforme donnée en utilisant platform_id.""" + games_path = f"/userdata/roms/ports/RGSX/games/{platform_id}.json" + #logger.debug(f"Chargement des jeux pour {platform_id} depuis {games_path}") + try: + with open(games_path, 'r', encoding='utf-8') as f: + games = json.load(f) + return games + except Exception as e: + logger.error(f"Erreur lors du chargement des jeux pour {platform_id} : {str(e)}") + return [] \ No newline at end of file