Initial commit with RGSX project files

This commit is contained in:
skymike03
2025-07-06 19:47:21 +02:00
commit 297fbaf0d2
9 changed files with 2925 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
logs/
images/
games/
_pycache_/
sources.json
gamelist.xml

738
__main__.py Normal file
View File

@@ -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())

59
config.py Normal file
View File

@@ -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

527
controls.py Normal file
View File

@@ -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

476
controls_mapper.py Normal file
View File

@@ -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)

548
display.py Normal file
View File

@@ -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 derreur."""
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 dune 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)

381
network.py Normal file
View File

@@ -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

78
update_gamelist.py Normal file
View File

@@ -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 <gameList>, 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 = '<?xml version="1.0" encoding="UTF-8"?>\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()

112
utils.py Normal file
View File

@@ -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 nest 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 []