diff --git a/README.md b/README.md index 781a501..9fcd811 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,56 @@ # duploGame -Jeu Pygame pour piloter 1 ou 2 trains LEGO DUPLO avec clavier PC ou contrôleurs arcade/joysticks. +Jeu Pygame pour piloter un ou deux trains LEGO DUPLO avec clavier PC ou contrôleurs arcade/joysticks. + +## Vue d'ensemble + +`duploGame` est une application de type arcade/borne conçue pour simuler le contrôle d'un train DUPLO en temps réel. Elle prend en charge : + +- le choix d'un mode 1 ou 2 joueurs, +- la connexion BLE à un ou plusieurs trains DUPLO, +- la configuration des mappings clavier et joystick, +- la gestion des déplacements, lumières, sons et arrêt d'urgence, +- le rendu d'une interface orientée borne arcade avec tablature visuelle. + +Le programme s'appuie sur `pygame` pour l'interface et sur `duplo_controller` pour la communication avec les hubs DUPLO. ## Fonctionnalités -- splash screen au démarrage -- menu de connexion de 1 ou 2 trains -- écran partagé en 2 zones de contrôle indépendantes -- support clavier (2 joueurs) + joysticks (1 joystick par joueur) -- intégration de `duploController` (asynchrone) pour piloter les trains +- splash screen au démarrage avec sélection 1 train / 2 trains, +- menu de connexion BLE pour 1 ou 2 joueurs, +- scan automatique des trains DUPLO visibles en Bluetooth, +- support clavier et joystick arcade, +- remapping temps réel via écrans de configuration (A REVOIR, mieux vaut passer directement par les fichiers JSON joystick_mappings.json et keyboard_mappings.json), +- sauvegarde des mappings dans des fichiers JSON, +- logique de vitesse, lumière, son, couleur et arrêt immédiat, +- mode simulation si `duploController` n'est pas installé. + +## Architecture du projet + +Le code est organisé autour de quelques composants clés : + +- `InputManager` : normalise les actionneurs clavier/joystick en événements simples (`up`, `down`, `start_pressed`, etc.). +- `TrainProxy` : encapsule la communication asynchrone avec un train unique. +- `DuploGame` : orchestre les écrans, la logique d'état et le rendu visuel. +- `AsyncLoopThread` : exécute la boucle asyncio dans un thread dédié pour éviter de bloquer la boucle Pygame. + +Cette séparation permet d'isoler le traitement du signal physique des commandes de transport BLE et rend le code plus facile à maintenir. ## Dépendances - Python 3.10+ - `pygame` - `duploController` : https://gitea.christophe-scaya.fr/scayac/duploController +- `bleak` (pour le scan BLE) ## Installation -Exemple: +Exemple de configuration locale : ```bash python -m venv .venv source .venv/bin/activate -pip install pygame +pip install pygame bleak pip install git+https://gitea.christophe-scaya.fr/scayac/duploController.git ``` @@ -33,35 +60,48 @@ pip install git+https://gitea.christophe-scaya.fr/scayac/duploController.git python main.py ``` +## Fichiers de configuration générés + +Au premier lancement, les écrans de configuration enregistrent automatiquement : + +- `joystick_mappings.json` : mapping physique des boutons du joystick pour les joueurs 1 et 2, +- `keyboard_mappings.json` : mapping clavier pour les deux joueurs. + +Ces fichiers sont rechargés au démarrage suivant. + ## Export Batocera (.pygame) -Un script d'export est disponible pour générer un package `.pygame`: +Un script d'export est disponible pour générer un package `.pygame` : ```bash python tools/export_batocera_pygame.py --name duploGame --entrypoint main.py ``` Le fichier est généré dans `dist/duploGame.pygame`. -Par défaut (`--mode folder`), l'export génère le dossier `dist/duploGame/` au format -style Retrotrivia avec: -- `dist/duploGame/duploGame.pygame` (script lanceur) -- les fichiers du jeu et assets à côté -- `vendor/` pour les modules Python embarqués +### Mode dossier (`--mode folder`) + +Par défaut, le script génère un dossier `dist/duploGame/` au format style Retrotrivia : -Ce format est généralement le plus compatible avec Batocera. +- `dist/duploGame/duploGame.pygame` (script lanceur), +- les fichiers du jeu et des assets à côté, +- `vendor/` pour les modules Python embarqués. -Le mode `single-file` reste disponible, mais moins recommandé: +C'est le format habituellement le plus compatible avec Batocera. + +### Mode single-file ```bash python tools/export_batocera_pygame.py --mode single-file ``` -Dans ce mode, l'export génère `dist/duploGame.pygame` et un dossier payload `dist/duploGame/`. +Dans ce mode, un seul fichier `dist/duploGame.pygame` est généré, avec un dossier payload `dist/duploGame/`. + +### Modules vendus inclus Par défaut, le script embarque les modules Python `duplo_controller`, `bleak` et `dbus_fast` dans `vendor/`. -Options utiles: +Options utiles : ```bash python tools/export_batocera_pygame.py \ @@ -75,83 +115,95 @@ python tools/export_batocera_pygame.py \ --vendor-module dbus_fast ``` -Si un module vendor n'est pas présent sur la machine de build: +Si un module est absent sur la machine de build : ```bash python tools/export_batocera_pygame.py --allow-missing-vendor ``` -Ensuite, copier vers Batocera: +### Mise en place sur Batocera -- mode `folder`: copier le dossier `dist/duploGame/` dans `/userdata/roms/pygame` -- mode `single-file`: copier `dist/duploGame.pygame` **et** `dist/duploGame/` dans `/userdata/roms/pygame` +- mode `folder` : copier le dossier `dist/duploGame/` dans `/userdata/roms/pygame` +- mode `single-file` : copier `dist/duploGame.pygame` et `dist/duploGame/` dans `/userdata/roms/pygame` ## Contrôles -### Menu +### Écran d'accueil -- `UP` / `DOWN` (ou joystick) : choisir 1 ou 2 joueurs -- `ACTION 1` joueur 1 : connecter/déconnecter train 1 -- `ACTION 1` joueur 2 : connecter/déconnecter train 2 (mode 2 joueurs) -- `START` joueur 1 : lancer le mode contrôle (si trains requis connectés) -- `SELECT` joueur 1 (ou touche `C`) : ouvrir la configuration joystick -- `ACTION 2` joueur 1 (ou touche `K`) : ouvrir la configuration clavier +- `1` / `2` : choisir 1 ou 2 trains, +- `UP` / `DOWN` : sélectionner le mode, +- `START` ou `ACTION 1` : valider et passer au menu, +- bouton engrenage : ouvrir l'écran de configuration, +- `ESC` : quitter. + +### Menu de connexion + +- `UP` / `DOWN` : choisir 1 ou 2 joueurs, +- `LEFT` / `RIGHT` : sélectionner le train dans la liste scanée, +- `ACTION 1` sur chaque joueur : connecter / déconnecter le train sélectionné, +- `START` joueur 1 : lancer le mode contrôle si les trains requis sont connectés, +- `SELECT` joueur 1 ou touche `C` : ouvrir la configuration joystick, +- `ACTION 2` joueur 1 ou touche `K` : ouvrir la configuration clavier. ### Configuration joystick -- `LEFT/RIGHT` : sélectionner joueur 1 ou 2 -- `UP/DOWN` : sélectionner la fonction à mapper (`UP`, `DOWN`, `LEFT`, `RIGHT`, `A1..A6`, `SELECT`, `START`) -- `ACTION 1` : entrer en mode capture, puis appuyer sur le bouton physique à associer -- `SELECT` : reset mapping par défaut du joueur sélectionné -- `START` : retour menu +- `LEFT/RIGHT` : sélectionner le joueur 1 ou 2, +- `UP/DOWN` : choisir la fonction à mapper (`UP`, `DOWN`, `LEFT`, `RIGHT`, `A1..A6`, `SELECT`, `START`), +- `ACTION 1` : entrer en mode capture, puis appuyer sur le bouton physique à associer, +- `SELECT` : réinitialiser le mapping du joueur courant, +- `START` : revenir au menu précédent. -Les mappings sont sauvegardés automatiquement dans `joystick_mappings.json` à la racine du projet, -et rechargés au prochain lancement du jeu. +Les mappings sont sauvegardés automatiquement dans `joystick_mappings.json` et rechargés au prochain lancement. -Important borne arcade: si votre encodeur expose le stick en boutons (et non en axes/hat), -mappez explicitement `UP`, `DOWN`, `LEFT`, `RIGHT` dans cet écran. +> Important pour les bornes arcade : si votre encodeur expose le stick en boutons plutôt qu'en axes/hat, il faut mapper explicitement `UP`, `DOWN`, `LEFT` et `RIGHT` dans l'écran de configuration. ### Configuration clavier -- `LEFT/RIGHT` : sélectionner joueur 1 ou 2 -- `UP/DOWN` : sélectionner la fonction à mapper (`UP`, `DOWN`, `LEFT`, `RIGHT`, `A1..A6`, `SELECT`, `START`) -- `ACTION 1` : entrer en mode capture, puis appuyer sur la touche clavier à associer -- `SELECT` : reset mapping clavier par défaut du joueur sélectionné -- `START` : retour menu +- `LEFT/RIGHT` : sélectionner le joueur 1 ou 2, +- `UP/DOWN` : choisir la fonction à mapper (`UP`, `DOWN`, `LEFT`, `RIGHT`, `A1..A6`, `SELECT`, `START`), +- `ACTION 1` : entrer en mode capture, puis appuyer sur la touche clavier à associer, +- `SELECT` : réinitialiser le mapping du joueur courant, +- `START` : revenir au menu précédent. -Les mappings clavier sont sauvegardés automatiquement dans `keyboard_mappings.json` à la racine du projet, -et rechargés au prochain lancement du jeu. +Les mappings clavier sont sauvegardés dans `keyboard_mappings.json`. ### En jeu -- `UP` / `DOWN` : accélérer / freiner -- `LEFT` / `RIGHT` : changer la couleur de la lumière -- `ACTION 1..5` : sons du train -- `ACTION 6` ou `SELECT` : arrêt immédiat -- `START` : retour menu -- `ESC` : quitter +- `UP` / `DOWN` : accélérer / freiner, +- `LEFT` / `RIGHT` : changer la couleur de la lumière, +- `ACTION 1..5` : jouer les sons du train, +- `ACTION 6` ou `SELECT` : arrêt immédiat, +- `START` : ouvrir le menu de connexion si le train n'est pas connecté, +- `ESC` : quitter. -### Mappings clavier +### Mappings clavier par défaut -- Joueur 1: +- Joueur 1 : - Directions: `W/Z`, `S`, `A/Q`, `D` - Actions 1..6: `1 2 3 4 5 6` - Start: `Entrée` - Select: `Backspace` -- Joueur 2: +- Joueur 2 : - Directions: `↑ ↓ ← →` - Actions 1..6: `U I O J K L` - Start: `Shift droit` - Select: `Ctrl droit` -### Mappings joystick arcade (par défaut) +### Mappings joystick arcade par défaut -- Directions: D-Pad (hat) ou axes gauche X/Y -- Actions 1..6: boutons `0..5` -- Select: bouton `6` -- Start: bouton `7` +- Directions : D-Pad (hat) ou axes gauche X/Y, +- Actions 1..6 : boutons `0..5`, +- Select : bouton `6`, +- Start : bouton `7`. Ces mappings sont modifiables à chaud via l'écran de configuration joystick. -> Si `duploController` n'est pas installé, le jeu démarre en mode simulation (sans contrôle BLE réel). +## Notes de fonctionnement + +- Si `duploController` n'est pas installé, l'application démarre en mode simulation et continue à fonctionner sans contrôle BLE réel. +- Le scan BLE est fait en arrière-plan dans un thread dédié pour éviter de bloquer le rendu. +- Les controllers identiques peuvent partager des identifiants SDL similaires ; le code évite ces collisions en associant les événements du joystick par index de périphérique au lieu de se fier uniquement à l'instance ID. +- Les capturations de touches ou boutons pendant la configuration sont désactivées temporairement pour éviter les boucles de détection entre le mapping en cours et les états actifs du joueur. + +> Pour le développement local, le plus simple est de lancer le jeu avec le fichier `main.py` et de tester la configuration des entrées depuis le menu d'options. diff --git a/duplogame.png b/duplogame.png new file mode 100644 index 0000000..3d31b69 Binary files /dev/null and b/duplogame.png differ diff --git a/fonts/LEGO.ttf b/fonts/LEGO.ttf new file mode 100644 index 0000000..4541feb Binary files /dev/null and b/fonts/LEGO.ttf differ diff --git a/img/board_1train.png b/img/board_1train.png new file mode 100644 index 0000000..17340b1 Binary files /dev/null and b/img/board_1train.png differ diff --git a/img/board_2trains.png b/img/board_2trains.png new file mode 100644 index 0000000..f2e0fd3 Binary files /dev/null and b/img/board_2trains.png differ diff --git a/img/engrenage.png b/img/engrenage.png new file mode 100644 index 0000000..5fca6ef Binary files /dev/null and b/img/engrenage.png differ diff --git a/img/main_screen.png b/img/main_screen.png new file mode 100644 index 0000000..0745593 Binary files /dev/null and b/img/main_screen.png differ diff --git a/img/train.png b/img/train.png deleted file mode 100644 index 9f208ef..0000000 Binary files a/img/train.png and /dev/null differ diff --git a/joystick_mappings.json b/joystick_mappings.json new file mode 100644 index 0000000..44fab70 --- /dev/null +++ b/joystick_mappings.json @@ -0,0 +1,34 @@ +{ + "player1": { + "actions": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "select": 6, + "start": 7, + "up": -1, + "down": -1, + "left": -1, + "right": -1 + }, + "player2": { + "actions": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "select": 6, + "start": 7, + "up": -1, + "down": -1, + "left": -1, + "right": -1 + } +} \ No newline at end of file diff --git a/main.py b/main.py index 90aceb9..a9d63b8 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,23 @@ +"""Duplo Arcade Controller. + +Ce module implémente un petit jeu/contrôleur Pygame pour piloter un ou deux trains +LEGO DUPLO via Bluetooth Low Energy et des entrées physiques (clavier, joystick +arcade, boutons de configuration). + +Architecture du programme : +- les états de l'application sont gérés par l'énumération GameState ; +- InputManager agrége le clavier/mécanique et normalise les pressions en états + exploitables par le moteur de jeu ; +- TrainProxy encapsule la communication asynchrone avec le hub du train ; +- DuploGame orchestre les écrans, la configuration, la connexion BLE et le gameplay. + +Le code a été structuré pour rester compatible avec des bornes arcade et des +environnements où plusieurs joysticks identiques peuvent être présentés par SDL. +""" + import asyncio import json +import os import threading import time from dataclasses import dataclass @@ -7,6 +25,10 @@ from enum import Enum, auto from pathlib import Path from typing import Dict, List, Optional, Tuple +# Désactive le driver HIDAPI de SDL pour éviter la renumérotation des boutons +# sur certaines versions de SDL (2.30+) avec les manettes DragonRise. +os.environ.setdefault("SDL_JOYSTICK_HIDAPI", "0") + import pygame try: @@ -40,8 +62,31 @@ GOOD = (86, 209, 124) WARN = (232, 188, 79) BAD = (225, 98, 98) +# Couleurs RGB correspondant aux DuploColor (indices 0–10) +DUPLO_COLOR_RGB = [ + (40, 40, 40), # BLACK = 0 + (255, 100, 180), # PINK = 1 + (148, 0, 211), # PURPLE = 2 + (30, 100, 255), # BLUE = 3 + (100, 180, 255), # LIGHTBLUE= 4 + (0, 220, 220), # CYAN = 5 + (50, 205, 50), # GREEN = 6 + (255, 220, 0), # YELLOW = 7 + (255, 140, 0), # ORANGE = 8 + (220, 50, 50), # RED = 9 + (255, 255, 255), # WHITE = 10 +] + class GameState(Enum): + """États principaux du programme. + + Chaque écran de l'interface correspond à un état distinct : écran d'accueil, + menu de connexion, mode de jeu, puis écrans de configuration pour joystick et + clavier. Cela permet de garder le code facilement lisible et de séparer la + logique d'affichage de la logique métier. + """ + SPLASH = auto() MENU = auto() CONTROL = auto() @@ -50,6 +95,14 @@ class GameState(Enum): class AsyncLoopThread: + """Petit pont autour d'une boucle asyncio dédiée. + + Les appels BLE sont réalisés depuis le thread Pygame, mais le transport + low-level du hub DUPLO est asynchrone. Cette classe crée un thread unique qui + exécute la boucle asyncio pour que les opérations réseau et l'UI restent + cohérentes sans bloquer l'affichage. + """ + def __init__(self): self._loop = asyncio.new_event_loop() self._thread = threading.Thread(target=self._run, daemon=True) @@ -69,6 +122,12 @@ class AsyncLoopThread: @dataclass class PlayerBindings: + """Représente l'ensemble des touches associées à un joueur. + + Une liste est utilisée pour permettre plusieurs touches possibles par action + (par exemple un clavier peut réagir à plusieurs codes pour une même direction). + """ + up: List[int] down: List[int] left: List[int] @@ -80,8 +139,16 @@ class PlayerBindings: @dataclass class PlayerInputState: + """Snapshot courant et transitionnel d'un joueur. + + Les champs *_pressed sont essentiels pour détecter les appuis instantanés et + éviter de répéter un action plusieurs fois pendant une seule pression. + """ + up: bool = False + up_pressed: bool = False down: bool = False + down_pressed: bool = False left: bool = False left_pressed: bool = False right: bool = False @@ -101,6 +168,14 @@ class PlayerInputState: class InputManager: + """Point d'entrée central pour les entrées du jeu. + + Cette classe remplace la logique de contrôle "brute" par un niveau de + normalisation. Le jeu ne s'intéresse ensuite qu'à des états comme `up`, + `actions_pressed`, `start_down`, etc., sans avoir à connaître la source exacte + (clavier ou joystick) ni le format concret de SDL. + """ + def __init__(self): self.bindings = { 0: PlayerBindings( @@ -233,16 +308,16 @@ class InputManager: def get_key_binding_rows(self, player: int): b = self.bindings[player] return [ - ("UP", b.up[0]), - ("DOWN", b.down[0]), - ("LEFT", b.left[0]), - ("RIGHT", b.right[0]), - ("A1", b.actions[0]), - ("A2", b.actions[1]), - ("A3", b.actions[2]), - ("A4", b.actions[3]), - ("A5", b.actions[4]), - ("A6", b.actions[5]), + ("AVANCE", b.up[0]), + ("RECULE", b.down[0]), + ("GAUCHE", b.left[0]), + ("DROITE", b.right[0]), + ("CHOIX LUMIERE", b.actions[0]), + ("LUMIERE ON", b.actions[1]), + ("CHOIX SON", b.actions[2]), + ("SON ON", b.actions[3]), + ("STOP", b.actions[4]), + ("SENS INVERSE", b.actions[5]), ("SELECT", b.select[0]), ("START", b.start[0]), ] @@ -345,7 +420,14 @@ class InputManager: def _key_any_pressed(keys, keycodes): return any(keys[k] for k in keycodes) - def update(self): + def update(self, skip_joy_player: int = -1, skip_key_player: int = -1): + """Met à jour l'état de chaque joueur à partir des signaux actuels. + + Les paramètres `skip_joy_player` et `skip_key_player` sont utilisés pendant la + configuration pour empêcher la capture d'un input déjà en cours de mapping. + Cela évite qu'un bouton en cours d'association soit immédiatement détecté + comme une nouvelle entrée valide. + """ keys = pygame.key.get_pressed() self.prev = {p: self.states[p] for p in self.states} self.states = {0: PlayerInputState(), 1: PlayerInputState()} @@ -353,29 +435,47 @@ class InputManager: for player in [0, 1]: b = self.bindings[player] s = self.states[player] - s.up = self._key_any_pressed(keys, b.up) - s.down = self._key_any_pressed(keys, b.down) - s.left = self._key_any_pressed(keys, b.left) - s.right = self._key_any_pressed(keys, b.right) - s.actions_down = [self._key_any_pressed(keys, [k]) for k in b.actions] - s.start_down = self._key_any_pressed(keys, b.start) - s.select_down = self._key_any_pressed(keys, b.select) + + # Désactiver le clavier si un joystick est présent (évite la double-détection + # causée par la traduction joystick→clavier de Batocera) + has_joy = self._joy_for_player(player) is not None + if player != skip_key_player and not has_joy: + s.up = self._key_any_pressed(keys, b.up) + s.down = self._key_any_pressed(keys, b.down) + s.left = self._key_any_pressed(keys, b.left) + s.right = self._key_any_pressed(keys, b.right) + s.actions_down = [self._key_any_pressed(keys, [k]) for k in b.actions] + s.start_down = self._key_any_pressed(keys, b.start) + s.select_down = self._key_any_pressed(keys, b.select) joy = self._joy_for_player(player) - if joy is not None: + if joy is not None and player != skip_joy_player: joy_map = self.joy_bindings[player] try: hat_x, hat_y = joy.get_hat(0) except Exception: hat_x, hat_y = 0, 0 - axis_x = joy.get_axis(0) if joy.get_numaxes() > 0 else 0 - axis_y = joy.get_axis(1) if joy.get_numaxes() > 1 else 0 - - s.up = s.up or hat_y > 0 or axis_y < -0.5 - s.down = s.down or hat_y < 0 or axis_y > 0.5 - s.left = s.left or hat_x < 0 or axis_x < -0.5 - s.right = s.right or hat_x > 0 or axis_x > 0.5 + # Détection axes uniquement si aucun bouton directionnel n'est configuré. + # Cela évite les interférences entre deux joysticks identiques (ex: DragonRise) + # qui peuvent partager les mêmes états d'axes. + dirs_mapped = ( + joy_map.get("up", -1) >= 0 or joy_map.get("down", -1) >= 0 + or joy_map.get("left", -1) >= 0 or joy_map.get("right", -1) >= 0 + ) + if not dirs_mapped: + axis_x = joy.get_axis(0) if joy.get_numaxes() > 0 else 0 + axis_y = joy.get_axis(1) if joy.get_numaxes() > 1 else 0 + axis_threshold = 0.3 + s.up = s.up or hat_y > 0 or axis_y < -axis_threshold + s.down = s.down or hat_y < 0 or axis_y > axis_threshold + s.left = s.left or hat_x < 0 or axis_x < -axis_threshold + s.right = s.right or hat_x > 0 or axis_x > axis_threshold + else: + s.up = s.up or hat_y > 0 + s.down = s.down or hat_y < 0 + s.left = s.left or hat_x < 0 + s.right = s.right or hat_x > 0 up_btn = joy_map.get("up", -1) down_btn = joy_map.get("down", -1) @@ -402,6 +502,8 @@ class InputManager: s.select_down = True prev = self.prev[player] + s.up_pressed = s.up and not prev.up + s.down_pressed = s.down and not prev.down s.left_pressed = s.left and not prev.left s.right_pressed = s.right and not prev.right s.actions_pressed = [now and not prev.actions_down[i] for i, now in enumerate(s.actions_down)] @@ -413,6 +515,14 @@ class InputManager: class TrainProxy: + """Adaptateur de façade pour un train DUPLO. + + Le but de cette classe est de masquer la complexité du transport BLE et des + asynchronismes de la lib `duplo_controller`. Le code du gameplay manipule des + méthodes lisibles comme `set_speed()`, `play_sound()` et `set_light()` plutôt + que des futures asyncio ou des appels réseau bruts. + """ + def __init__(self, loop_thread: AsyncLoopThread, address: Optional[str] = None): self.loop_thread = loop_thread self.address = address @@ -507,18 +617,79 @@ class TrainProxy: class DuploGame: + """Orchestrateur de tout le jeu. + + Cette classe centralise le cycle de vie complet de l'application : + - initialisation des ressources SDL et des polices ; + - chargement des assets visuels ; + - gestion des écrans et états du jeu ; + - connexion/disconnexion des trains ; + - configuration des mappings clavier/joystick ; + - rendu de l'interface, des overlays et de l'état de jeu. + """ + def __init__(self): pygame.init() pygame.display.set_caption("Duplo Arcade Controller") - self.screen = pygame.display.set_mode((WIDTH, HEIGHT)) + self.screen = pygame.display.set_mode((WIDTH, HEIGHT), pygame.FULLSCREEN | pygame.SCALED) self.clock = pygame.time.Clock() - self.title_font = pygame.font.SysFont("arial", 56, bold=True) - self.big_font = pygame.font.SysFont("arial", 36, bold=True) - self.font = pygame.font.SysFont("arial", 24) - self.small_font = pygame.font.SysFont("arial", 20) + _lego_ttf = str(Path("fonts") / "LEGO.ttf") + def _font(size): + try: + return pygame.font.Font(_lego_ttf, size) + except Exception: + return pygame.font.SysFont("arial", size) + self.title_font = _font(56) + self.big_font = _font(36) + self.font = _font(24) + self.small_font = _font(20) self.splash_image = self._load_splash_image() + self.gear_image = self._load_gear_image() + self.board_images = [ + self._load_scaled_image(Path("img") / "board_1train.png"), + self._load_scaled_image(Path("img") / "board_2trains.png"), + ] + + # Button overlay positions: {player_count: [[player0 buttons], [player1 buttons]]} + # Each button: (cx, cy, radius, action_key) + # action_key: 'up','down','left','right','select','start','a0'..'a5' + _p0_1 = [ + (447, 99, 30, 'start'), # cercle gauche → START + (563, 99, 30, 'select'), # cercle droit → SELECT + (142, 385, 30, 'up'), # grande flèche ↑ → VITESSE + + (142, 572, 30, 'down'), # grande flèche ↓ → VITESSE - + (307, 445, 30, 'a4'), # bouton centre 1 → A5 + (307, 532, 30, 'a5'), # bouton centre 2 → A6 + (421, 395, 30, 'a0'), # choix couleur ↑ → A1 + (421, 477, 30, 'a1'), # choix couleur ↓ → A2 + (540, 395, 30, 'a2'), # choix son ↑ → A3 + (540, 475, 30, 'a3'), # choix son ↓ → A4 + ] + _p0_2 = [ + (725, 99, 30, 'start'), + (837, 99, 30, 'select'), + (770, 385, 30, 'up'), + (770, 572, 30, 'down'), + (924, 445, 30, 'a4'), + (924, 530, 30, 'a5'), + (1041, 417, 30, 'a0'), + (1041, 500, 30, 'a1'), + (1165, 417, 30, 'a2'), + (1165, 500, 30, 'a3'), + ] + self._control_buttons: Dict[int, List[List[Tuple]]] = { + 1: [_p0_1], + 2: [_p0_1, _p0_2], + } + + self.splash_selection = 0 + self.splash_settings_open = False + self.splash_settings_idx = 0 # 0=joystick, 1=clavier + self._config_return_state = GameState.SPLASH + self._joy_config_cooldown = 0 # timestamp fin de protection post-validation + self._joy_config_last_btn = -1 # dernier bouton validé (pour attendre son relâchement) self.state = GameState.SPLASH self.splash_started = time.time() @@ -535,7 +706,9 @@ class DuploGame: self.addresses = ["", ""] self.status_lines = ["appuyer ACTION 1 pour connecter", "disponible si mode 2 joueurs"] self.speed_targets = [0, 0] - self.color_index = [0, 0] + self.color_index = [3, 3] # 3 = BLUE par défaut + self.light_on = [False, False] + self.sound_index = [0, 0] self.speed_adjust_interval = 0.08 self.speed_adjust_step = 4 self.last_speed_adjust = [0.0, 0.0] @@ -554,14 +727,18 @@ class DuploGame: self.discovered_trains: List[Dict[str, object]] = [] self.selected_train_index = [0, 0] + self.bt_menu_open = [False, False] self.scan_attempted = False + self.scan_in_progress = False + self._scan_future = None self._load_joy_mappings() self._load_key_mappings() - self.colors = [] + self.duplo_colors = [] if HAS_DUPLO_LIB: - self.colors = [ + self.duplo_colors = [ + DuploColor.BLACK, DuploColor.PINK, DuploColor.PURPLE, DuploColor.BLUE, @@ -577,7 +754,16 @@ class DuploGame: def run(self): while self.running: self._handle_events() - self.input.update() + + # Déterminer si on doit ignorer les inputs d'un joueur pendant le remapping + skip_joy_player = -1 + skip_key_player = -1 + if self.state == GameState.JOY_CONFIG and (self.awaiting_joy_button or time.time() < self._joy_config_cooldown): + skip_joy_player = self.config_player + if self.state == GameState.KEY_CONFIG and self.awaiting_key_press: + skip_key_player = self.key_config_player + + self.input.update(skip_joy_player=skip_joy_player, skip_key_player=skip_key_player) if self.state == GameState.SPLASH: self._update_splash() @@ -619,11 +805,70 @@ class DuploGame: self.input.refresh_joysticks() def _update_splash(self): - elapsed = time.time() - self.splash_started p0 = self.input.state(0) p1 = self.input.state(1) - if elapsed >= SPLASH_DURATION or p0.start_pressed or p1.start_pressed or p0.actions_pressed[0]: - self.state = GameState.MENU + + # Gear button bounds (bottom-right) + gear_cx, gear_cy, gear_r = WIDTH - 60, HEIGHT - 60, 35 + + for event in self.frame_events: + if event.type == pygame.KEYDOWN: + if not self.splash_settings_open: + if event.key == pygame.K_1: + self.splash_selection = 0 + elif event.key == pygame.K_2: + self.splash_selection = 1 + elif event.key == pygame.K_TAB: + self.splash_settings_open = True + else: + if event.key in (pygame.K_ESCAPE, pygame.K_TAB): + self.splash_settings_open = False + elif event.key == pygame.K_UP: + self.splash_settings_idx = (self.splash_settings_idx - 1) % 2 + elif event.key == pygame.K_DOWN: + self.splash_settings_idx = (self.splash_settings_idx + 1) % 2 + elif event.key in (pygame.K_RETURN, pygame.K_SPACE): + self._open_settings_from_splash() + elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: + mx, my = event.pos + if (mx - gear_cx) ** 2 + (my - gear_cy) ** 2 <= gear_r ** 2: + self.splash_settings_open = not self.splash_settings_open + + if self.splash_settings_open: + if p0.up_pressed or p1.up_pressed: + self.splash_settings_idx = (self.splash_settings_idx - 1) % 2 + elif p0.down_pressed or p1.down_pressed: + self.splash_settings_idx = (self.splash_settings_idx + 1) % 2 + if p0.actions_pressed[0] or p1.actions_pressed[0]: + self._open_settings_from_splash() + if p0.select_pressed or p1.select_pressed: + self.splash_settings_open = False + return # block normal splash navigation while overlay is open + + if p0.up or p0.left or p1.up or p1.left: + self.splash_selection = 0 + elif p0.down or p0.right or p1.down or p1.right: + self.splash_selection = 1 + + confirmed = ( + p0.start_pressed or p1.start_pressed + or p0.actions_pressed[0] or p1.actions_pressed[0] + ) + if confirmed: + self.player_count = self.splash_selection + 1 + self.state = GameState.CONTROL + + def _open_settings_from_splash(self): + self.splash_settings_open = False + self._config_return_state = GameState.SPLASH + if self.splash_settings_idx == 0: + self.state = GameState.JOY_CONFIG + self.awaiting_joy_button = False + self.config_message = "Config joystick: ACTION 1 pour modifier" + else: + self.state = GameState.KEY_CONFIG + self.awaiting_key_press = False + self.key_config_message = "Config clavier: ACTION 1 pour modifier" def _update_menu(self): p0 = self.input.state(0) @@ -631,6 +876,7 @@ class DuploGame: if not self.scan_attempted: self._scan_available_trains() + self._poll_scan_result() any_up = p0.up or p1.up any_down = p0.down or p1.down @@ -712,13 +958,31 @@ class DuploGame: return result def _scan_available_trains(self): + if self.scan_in_progress: + return self.scan_attempted = True + self.scan_in_progress = True if not HAS_BLEAK: + self.scan_in_progress = False self.status_lines[0] = "BLE scanner indisponible (bleak introuvable)" return try: - fut = self.ble_loop.run(self._discover_duplo_trains_async()) - self.discovered_trains = fut.result(timeout=8.0) + self._scan_future = self.ble_loop.run(self._discover_duplo_trains_async()) + self.status_lines[0] = "Scan BLE en cours..." + except Exception as exc: + self.scan_in_progress = False + self._scan_future = None + self.discovered_trains = [] + self.status_lines[0] = f"erreur scan BLE: {exc}" + + def _poll_scan_result(self): + if not self.scan_in_progress or self._scan_future is None: + return + if not self._scan_future.done(): + return + self.scan_in_progress = False + try: + self.discovered_trains = self._scan_future.result() self.selected_train_index = [0, 0] if self.discovered_trains: self.status_lines[0] = f"{len(self.discovered_trains)} train(s) trouvé(s)" @@ -729,6 +993,7 @@ class DuploGame: except Exception as exc: self.discovered_trains = [] self.status_lines[0] = f"erreur scan BLE: {exc}" + self._scan_future = None def _cycle_selected_train(self, player: int, delta: int): if not self.discovered_trains: @@ -748,14 +1013,18 @@ class DuploGame: train = self.trains[player] if train.connected: ok, msg = train.disconnect() + if ok: + self.light_on[player] = False self.status_lines[player] = ("OK: " if ok else "KO: ") + msg return if not self.discovered_trains: - self._scan_available_trains() - if not self.discovered_trains: - self.status_lines[player] = "KO: aucun train disponible" + if self.scan_in_progress: + self.status_lines[player] = "Scan en cours, veuillez patienter..." return + self._scan_available_trains() + self.status_lines[player] = "Scan BLE démarré..." + return chosen = self._selected_train_for_player(player) if chosen is None: @@ -770,6 +1039,8 @@ class DuploGame: train.set_address(chosen_addr) ok, msg = train.connect() + if ok: + self._on_train_connected(player) train_name = chosen.get("name", "DUPLO Hub") self.status_lines[player] = ("OK: " if ok else "KO: ") + f"{train_name} {msg}" @@ -779,29 +1050,65 @@ class DuploGame: train = self.trains[player] if train.connected: ok, msg = train.disconnect() + if ok: + self.light_on[player] = False else: ok, msg = train.connect() + if ok: + self._on_train_connected(player) self.status_lines[player] = ("OK: " if ok else "KO: ") + msg - def _sound_for_action(self, idx: int): + def _on_train_connected(self, player: int): + self.color_index[player] = 3 # BLUE + self.light_on[player] = True + if self.duplo_colors: + self.trains[player].set_light(self.duplo_colors[3]) + + # Sonneries disponibles dans l'ordre affiché (1..5) + DUPLO_SOUNDS = None # initialisé après import + + def _get_duplo_sounds(self): if not HAS_DUPLO_LIB: - return None - sounds = [ - DuploSound.HORN, + return [] + return [ DuploSound.BRAKE, - DuploSound.STEAM, DuploSound.STATION_DEPARTURE, DuploSound.WATER_REFILL, - None, + DuploSound.HORN, + DuploSound.STEAM, ] - return sounds[idx] def _update_control(self): now = time.time() + + # Poll BLE scan results whenever a connection menu is open + if any(self.bt_menu_open[:self.player_count]): + self._poll_scan_result() + for player in range(self.player_count): state = self.input.state(player) train = self.trains[player] + # ── BT connection menu overlay ────────────────────────────────── + if self.bt_menu_open[player]: + if state.up_pressed: + self._cycle_selected_train(player, -1) + if state.down_pressed: + self._cycle_selected_train(player, +1) + if state.start_pressed: + if self.scan_in_progress: + pass # still scanning, nothing to do yet + elif not self.discovered_trains: + self._scan_available_trains() + else: + self._connect_selected_or_toggle(player) + if train.connected: + self.bt_menu_open[player] = False + if state.select_pressed: + self.bt_menu_open[player] = False + continue # skip normal gameplay while menu is open + + # ── Normal gameplay ───────────────────────────────────────────── delta = 0 if state.up: delta += 2 @@ -816,46 +1123,87 @@ class DuploGame: train.flush_speed() - if state.actions_pressed[5]: + # a4 : STOP + if state.actions_pressed[4]: self.speed_targets[player] = 0 train.stop() - for action_idx in range(5): - if state.actions_pressed[action_idx]: - sound = self._sound_for_action(action_idx) - if sound is not None: - train.play_sound(sound) + # a5 : changement de sens + if state.actions_pressed[5]: + self.speed_targets[player] = -self.speed_targets[player] + train.set_speed(self.speed_targets[player]) - if state.left_pressed and self.colors: - self.color_index[player] = (self.color_index[player] - 1) % len(self.colors) - train.set_light(self.colors[self.color_index[player]]) - if state.right_pressed and self.colors: - self.color_index[player] = (self.color_index[player] + 1) % len(self.colors) - train.set_light(self.colors[self.color_index[player]]) + # a2 : choix sonnerie (cycle 1-5) + if state.actions_pressed[2]: + sounds = self._get_duplo_sounds() + n = len(sounds) if sounds else 5 + self.sound_index[player] = (self.sound_index[player] + 1) % n + + # a3 : jouer la sonnerie sélectionnée + if state.actions_pressed[3]: + sounds = self._get_duplo_sounds() + if sounds: + train.play_sound(sounds[self.sound_index[player]]) + + if state.left_pressed and self.duplo_colors: + n = len(self.duplo_colors) + self.color_index[player] = (self.color_index[player] - 1) % n + if self.light_on[player]: + train.set_light(self.duplo_colors[self.color_index[player]]) + if state.right_pressed and self.duplo_colors: + n = len(self.duplo_colors) + self.color_index[player] = (self.color_index[player] + 1) % n + if self.light_on[player]: + train.set_light(self.duplo_colors[self.color_index[player]]) + + # a0 : choix couleur (cycle) — ne modifie pas la lumière du train + if state.actions_pressed[0]: + n = len(self.duplo_colors) if self.duplo_colors else len(DUPLO_COLOR_RGB) + self.color_index[player] = (self.color_index[player] + 1) % n + + # a1 : allumer / éteindre la lumière avec la couleur paramétrée + if state.actions_pressed[1]: + if self.light_on[player]: + self.light_on[player] = False + if self.duplo_colors: + train.set_light(self.duplo_colors[0]) # BLACK = éteint + else: + self.light_on[player] = True + if self.duplo_colors: + train.set_light(self.duplo_colors[self.color_index[player]]) if state.select_pressed: - self.speed_targets[player] = 0 - train.stop() + if train.connected: + self.speed_targets[player] = 0 + train.stop() + ok, msg = train.disconnect() + self.light_on[player] = False + self.status_lines[player] = ("OK: " if ok else "KO: ") + msg if state.start_pressed: - self.state = GameState.MENU - self.status_lines[player] = "retour menu" + if not train.connected: + self.bt_menu_open[player] = True + self.selected_train_index[player] = 0 + if not self.scan_in_progress: + self.discovered_trains = [] + self._scan_available_trains() + # If already connected, START does nothing def _update_joy_config(self): p0 = self.input.state(0) if p0.start_pressed and not self.awaiting_joy_button: - self.state = GameState.MENU + self.state = self._config_return_state self.config_message = "Retour menu" return if not self.awaiting_joy_button: - if p0.left: + if p0.left_pressed: self.config_player = 0 - if p0.right: + if p0.right_pressed: self.config_player = 1 - if p0.up: + if p0.up_pressed: self.config_slot_idx = (self.config_slot_idx - 1) % len(self.config_slots) - if p0.down: + if p0.down_pressed: self.config_slot_idx = (self.config_slot_idx + 1) % len(self.config_slots) if p0.actions_pressed[0]: self.awaiting_joy_button = True @@ -876,7 +1224,9 @@ class DuploGame: for event in self.frame_events: if event.type != pygame.JOYBUTTONDOWN: continue - event_player = self.input.player_for_instance_id(event.instance_id) + # Utiliser event.joy (index) plutôt que instance_id, plus fiable + # avec des contrôleurs identiques (ex: deux DragonRise) + event_player = event.joy if event.joy < len(self.input.joysticks) else None if event_player is None: continue if event_player != expected_player: @@ -885,24 +1235,25 @@ class DuploGame: self.input.set_joy_binding(expected_player, slot, event.button) self._save_joy_mappings() self.awaiting_joy_button = False + self._joy_config_cooldown = time.time() + 0.5 # bloque 500ms après validation self.config_message = f"J{expected_player + 1} {slot} = bouton {event.button}" break def _update_key_config(self): p0 = self.input.state(0) if p0.start_pressed and not self.awaiting_key_press: - self.state = GameState.MENU + self.state = self._config_return_state self.key_config_message = "Retour menu" return if not self.awaiting_key_press: - if p0.left: + if p0.left_pressed: self.key_config_player = 0 - if p0.right: + if p0.right_pressed: self.key_config_player = 1 - if p0.up: + if p0.up_pressed: self.key_config_slot_idx = (self.key_config_slot_idx - 1) % len(self.key_config_slots) - if p0.down: + if p0.down_pressed: self.key_config_slot_idx = (self.key_config_slot_idx + 1) % len(self.key_config_slots) if p0.actions_pressed[0]: self.awaiting_key_press = True @@ -983,49 +1334,93 @@ class DuploGame: surface.blit(s, r) def _load_splash_image(self): - image_path = Path("img") / "train.png" + return self._load_scaled_image(Path("img") / "main_screen.png") + + def _load_gear_image(self): + path = Path("img") / "engrenage.png" + if not path.exists(): + return None + try: + return pygame.image.load(path.as_posix()).convert_alpha() + except Exception: + return None + + def _load_scaled_image(self, image_path: Path): if not image_path.exists(): return None try: image = pygame.image.load(image_path.as_posix()).convert_alpha() - w, h = image.get_size() - max_w = int(WIDTH * 0.55) - max_h = int(HEIGHT * 0.45) - scale = min(max_w / w, max_h / h) - if scale < 1: - image = pygame.transform.smoothscale(image, (int(w * scale), int(h * scale))) - return image + return pygame.transform.smoothscale(image, (WIDTH, HEIGHT)) except Exception: return None def _draw_splash(self): - self.screen.fill(BG) - y_title = HEIGHT // 2 - 40 if self.splash_image is not None: - img_rect = self.splash_image.get_rect(center=(WIDTH // 2, HEIGHT // 2 - 110)) - self.screen.blit(self.splash_image, img_rect) - y_title = HEIGHT // 2 + 120 - - self._draw_text(self.screen, "DUPLO ARCADE", self.title_font, TXT, WIDTH // 2, y_title, center=True) - self._draw_text( - self.screen, - "Contrôle clavier/joystick - Appuyer START ou attendre", - self.font, - WARN, - WIDTH // 2, - y_title + 64, - center=True, - ) - if not HAS_DUPLO_LIB: - self._draw_text( - self.screen, - "duploController non trouvé: mode simulation actif", - self.small_font, - BAD, - WIDTH // 2, - y_title + 110, - center=True, - ) + self.screen.blit(self.splash_image, (0, 0)) + else: + self.screen.fill(BG) + + btn_w, btn_h = 360, 86 + btn_y = HEIGHT - 130 + gap = 220 + centers = [(WIDTH // 2 - gap, btn_y), (WIDTH // 2 + gap, btn_y)] + labels = ["1 Train", "2 Trains"] + + radius = 18 + for idx, (cx, cy) in enumerate(centers): + rect = pygame.Rect(0, 0, btn_w, btn_h) + rect.center = (cx, cy) + selected = idx == self.splash_selection + bg_surf = pygame.Surface((btn_w, btn_h), pygame.SRCALPHA) + bg_color = (59, 76, 103, 230) if selected else (25, 33, 46, 210) + pygame.draw.rect(bg_surf, bg_color, bg_surf.get_rect(), border_radius=radius) + self.screen.blit(bg_surf, rect.topleft) + border_color = GOOD if selected else PANEL_BORDER + pygame.draw.rect(self.screen, border_color, rect, 3, border_radius=radius) + txt_color = GOOD if selected else TXT + self._draw_text(self.screen, labels[idx], self.big_font, txt_color, cx, cy, center=True) + + # Gear button (bottom-right) + gear_cx, gear_cy, gear_r = WIDTH - 60, HEIGHT - 60, 35 + gear_bg = pygame.Surface((gear_r * 2, gear_r * 2), pygame.SRCALPHA) + gear_col = (86, 209, 124, 220) if self.splash_settings_open else (59, 76, 103, 200) + pygame.draw.circle(gear_bg, gear_col, (gear_r, gear_r), gear_r) + self.screen.blit(gear_bg, (gear_cx - gear_r, gear_cy - gear_r)) + border_col = GOOD if self.splash_settings_open else PANEL_BORDER + pygame.draw.circle(self.screen, border_col, (gear_cx, gear_cy), gear_r, 2) + if self.gear_image is not None: + icon_size = gear_r * 2 - 14 + icon = pygame.transform.smoothscale(self.gear_image, (icon_size, icon_size)) + self.screen.blit(icon, (gear_cx - icon_size // 2, gear_cy - icon_size // 2)) + else: + self._draw_text(self.screen, "?", self.big_font, TXT, gear_cx, gear_cy, center=True) + + # Settings overlay + if self.splash_settings_open: + ov_w, ov_h = 460, 200 + ov_x = (WIDTH - ov_w) // 2 + ov_y = (HEIGHT - ov_h) // 2 + ov_surf = pygame.Surface((ov_w, ov_h), pygame.SRCALPHA) + ov_surf.fill((14, 18, 40, 240)) + self.screen.blit(ov_surf, (ov_x, ov_y)) + pygame.draw.rect(self.screen, PANEL_BORDER, pygame.Rect(ov_x, ov_y, ov_w, ov_h), 2, border_radius=14) + self._draw_text(self.screen, "Configuration", self.font, TXT, ov_x + ov_w // 2, ov_y + 26, center=True) + options = ["Config Joystick", "Config Clavier"] + for i, label in enumerate(options): + item_y = ov_y + 74 + i * 52 + selected = i == self.splash_settings_idx + item_bg = pygame.Surface((ov_w - 40, 40), pygame.SRCALPHA) + item_bg.fill((59, 76, 103, 200) if selected else (25, 33, 46, 160)) + self.screen.blit(item_bg, (ov_x + 20, item_y - 4)) + if selected: + pygame.draw.rect(self.screen, GOOD, + pygame.Rect(ov_x + 20, item_y - 4, ov_w - 40, 40), 2, border_radius=8) + col = GOOD if selected else TXT + prefix = "> " if selected else " " + self._draw_text(self.screen, prefix + label, self.font, col, + ov_x + ov_w // 2, item_y + 16, center=True) + self._draw_text(self.screen, "UP/BAS: nav \u2022 ACTION 1: ouvrir \u2022 SELECT/Tab: fermer", + self.small_font, (130, 145, 165), ov_x + ov_w // 2, ov_y + ov_h - 18, center=True) def _draw_menu(self): self.screen.fill(BG) @@ -1042,7 +1437,6 @@ class DuploGame: ) self._draw_text(self.screen, "SELECT P1 ou touche C: config joystick", self.small_font, WARN, 40, 172) self._draw_text(self.screen, "ACTION 2 P1 ou touche K: config clavier", self.small_font, WARN, 40, 198) - self._draw_text(self.screen, "LEFT/RIGHT joueur: choisir train trouvé | ACTION 1: connecter", self.small_font, TXT, 40, 224) if self.discovered_trains: preview = [] @@ -1113,23 +1507,23 @@ class DuploGame: joy = self.input._joy_for_player(player) if joy is None: - self._draw_text(self.screen, "Aucun joystick détecté pour ce joueur", self.small_font, BAD, x + 16, y + 46) + self._draw_text(self.screen, "Aucun joystick pour ce joueur !", self.small_font, BAD, x + 16, y + 46) else: name = joy.get_name() self._draw_text(self.screen, f"Joystick: {name}", self.small_font, TXT, x + 16, y + 46) mapping = self.input.get_joy_bindings(player) rows = [ - ("UP", mapping.get("up", -1)), - ("DOWN", mapping.get("down", -1)), - ("LEFT", mapping.get("left", -1)), - ("RIGHT", mapping.get("right", -1)), - ("A1", mapping["actions"][0]), - ("A2", mapping["actions"][1]), - ("A3", mapping["actions"][2]), - ("A4", mapping["actions"][3]), - ("A5", mapping["actions"][4]), - ("A6", mapping["actions"][5]), + ("AVANCE", mapping.get("up", -1)), + ("RECULE", mapping.get("down", -1)), + ("GAUCHE", mapping.get("left", -1)), + ("DROITE", mapping.get("right", -1)), + ("CHOIX LUMIERE", mapping["actions"][0]), + ("LUMIERE ON", mapping["actions"][1]), + ("CHOIX SON", mapping["actions"][2]), + ("SON ON", mapping["actions"][3]), + ("STOP", mapping["actions"][4]), + ("SENS INVERSE", mapping["actions"][5]), ("SELECT", mapping["select"]), ("START", mapping["start"]), ] @@ -1138,7 +1532,7 @@ class DuploGame: is_selected_slot = selected_player and idx == self.config_slot_idx line_color = WARN if is_selected_slot else TXT prefix = "> " if is_selected_slot else " " - button_label = "désactivé" if button < 0 else f"bouton {button}" + button_label = "INCONNU" if button < 0 else f"bouton {button}" self._draw_text(self.screen, f"{prefix}{slot}", self.small_font, line_color, x + 16, line_y) self._draw_text(self.screen, button_label, self.small_font, line_color, x + 150, line_y) @@ -1176,57 +1570,151 @@ class DuploGame: msg_color = BAD if self.awaiting_key_press else TXT self._draw_text(self.screen, self.key_config_message, self.small_font, msg_color, 40, HEIGHT - 46) - def _draw_control(self): - self.screen.fill(BG) - panel_w = (WIDTH - 80 - 20) // 2 - - for i in [0, 1]: - x = 40 + i * (panel_w + 20) - y = 40 - h = HEIGHT - 80 - rect = pygame.Rect(x, y, panel_w, h) - pygame.draw.rect(self.screen, PANEL_BG, rect, border_radius=12) - pygame.draw.rect(self.screen, PANEL_BORDER, rect, 2, border_radius=12) - - active = i < self.player_count - title_color = TXT if active else (130, 130, 130) - self._draw_text(self.screen, f"Joueur {i + 1}", self.big_font, title_color, x + 20, y + 20) - - if not active: - self._draw_text(self.screen, "Zone inactive", self.font, WARN, x + 20, y + 90) - continue - - connected = self.trains[i].connected - self._draw_text( - self.screen, - f"Train: {'connecté' if connected else 'déconnecté'}", - self.font, - GOOD if connected else BAD, - x + 20, - y + 90, - ) + @staticmethod + def _ctrl_btn_active(state: "PlayerInputState", action_key: str) -> bool: + if action_key == "up": return state.up + if action_key == "down": return state.down + if action_key == "left": return state.left + if action_key == "right": return state.right + if action_key == "select": return state.select_down + if action_key == "start": return state.start_down + if action_key.startswith("a"): + idx = int(action_key[1:]) + return bool(state.actions_down[idx]) if idx < len(state.actions_down) else False + return False + + def _draw_bt_menu_overlay(self, player: int): + panel_x = 30 if player == 0 else 660 + panel_y = 20 + panel_w = 590 + panel_h = 300 + + bg_surf = pygame.Surface((panel_w, panel_h), pygame.SRCALPHA) + bg_surf.fill((10, 14, 35, 240)) + self.screen.blit(bg_surf, (panel_x, panel_y)) + rect = pygame.Rect(panel_x, panel_y, panel_w, panel_h) + pygame.draw.rect(self.screen, PANEL_BORDER, rect, 2, border_radius=12) + + title = f"Connexion BLE — Joueur {player + 1}" + self._draw_text(self.screen, title, self.font, TXT, panel_x + 16, panel_y + 12) + + if self.scan_in_progress: + self._draw_text(self.screen, "Scan Bluetooth en cours...", self.small_font, WARN, + panel_x + 16, panel_y + 54) + elif not self.discovered_trains: + self._draw_text(self.screen, "Aucun train trouvé.", self.small_font, BAD, + panel_x + 16, panel_y + 54) + self._draw_text(self.screen, "Appuyez START pour relancer le scan.", + self.small_font, TXT, panel_x + 16, panel_y + 80) + else: + count = len(self.discovered_trains) + self._draw_text(self.screen, f"{count} train(s) disponible(s) :", self.small_font, TXT, + panel_x + 16, panel_y + 50) + selected_idx = self.selected_train_index[player] % count + other = 1 - player + for i, info in enumerate(self.discovered_trains[:7]): + line_y = panel_y + 76 + i * 26 + is_sel = (i == selected_idx) + addr = info.get("address", "?") + in_use = (self.player_count == 2 + and self.trains[other].connected + and self.trains[other].address == addr) + name = info.get("name", "DUPLO Hub") + used_txt = " [utilisé]" if in_use else "" + prefix = "> " if is_sel else " " + txt_color = GOOD if is_sel else (WARN if in_use else TXT) + self._draw_text(self.screen, f"{prefix}{name}{used_txt}", + self.small_font, txt_color, panel_x + 16, line_y) + addr_color = WARN if is_sel else (160, 170, 185) + self._draw_text(self.screen, str(addr), + self.small_font, addr_color, panel_x + 300, line_y) + + hint = "HAUT/BAS : naviguer START : connecter SELECT : annuler" + self._draw_text(self.screen, hint, self.small_font, (130, 145, 165), + panel_x + 16, panel_y + panel_h - 28) - speed = self.speed_targets[i] - self._draw_text(self.screen, f"Vitesse cible: {speed}", self.font, TXT, x + 20, y + 130) - bar_w = panel_w - 40 - bar_h = 28 - bar_x = x + 20 - bar_y = y + 170 - pygame.draw.rect(self.screen, (40, 51, 68), (bar_x, bar_y, bar_w, bar_h), border_radius=6) - center_x = bar_x + bar_w // 2 - pygame.draw.line(self.screen, (80, 95, 115), (center_x, bar_y), (center_x, bar_y + bar_h), 2) - fill = int((abs(speed) / 100.0) * (bar_w // 2)) - if speed >= 0: - pygame.draw.rect(self.screen, GOOD, (center_x, bar_y + 4, fill, bar_h - 8), border_radius=5) - else: - pygame.draw.rect(self.screen, BAD, (center_x - fill, bar_y + 4, fill, bar_h - 8), border_radius=5) + def _draw_control(self): + board_img = self.board_images[self.player_count - 1] if 1 <= self.player_count <= 2 else None + if board_img is not None: + self.screen.blit(board_img, (0, 0)) + else: + self.screen.fill(BG) - self._draw_text(self.screen, "UP/DOWN: accélérer/freiner", self.small_font, TXT, x + 20, y + 240) - self._draw_text(self.screen, "LEFT/RIGHT: changer lumière", self.small_font, TXT, x + 20, y + 270) - self._draw_text(self.screen, "A1..A5: sons | A6/Select: stop", self.small_font, TXT, x + 20, y + 300) - self._draw_text(self.screen, "Start: retour menu", self.small_font, TXT, x + 20, y + 330) + buttons_by_player = self._control_buttons.get(self.player_count, []) + for player in range(self.player_count): + if player >= len(buttons_by_player): + break + state = self.input.state(player) + train = self.trains[player] + for cx, cy, r, action_key in buttons_by_player[player]: + # Indicateurs persistants quand le train est connecté + if train.connected: + if action_key == 'a0': + rgb = DUPLO_COLOR_RGB[self.color_index[player]] + overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) + pygame.draw.circle(overlay, (*rgb, 200), (r, r), r) + self.screen.blit(overlay, (cx - r, cy - r)) + elif action_key == 'a1': + col = (56, 220, 90) if self.light_on[player] else (100, 100, 100) + overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) + pygame.draw.circle(overlay, (*col, 160), (r, r), r) + self.screen.blit(overlay, (cx - r, cy - r)) + elif action_key == 'a2': + overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) + pygame.draw.circle(overlay, (232, 188, 79, 180), (r, r), r) + self.screen.blit(overlay, (cx - r, cy - r)) + label = str(self.sound_index[player] + 1) + lbl_surf = self.small_font.render(label, True, (30, 30, 30)) + lbl_r = lbl_surf.get_rect(center=(cx, cy)) + self.screen.blit(lbl_surf, lbl_r) + elif action_key == 'start': + overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) + pygame.draw.circle(overlay, (30, 100, 255, 200), (r, r), r) + self.screen.blit(overlay, (cx - r, cy - r)) + ok_surf = self.small_font.render("OK", True, (255, 255, 255)) + ok_r = ok_surf.get_rect(center=(cx, cy)) + self.screen.blit(ok_surf, ok_r) + # Overlay d'appui actif + if self._ctrl_btn_active(state, action_key): + color = (56, 220, 90, 210) + if action_key in ('up', 'down'): + # Triangles avec coordonnées absolues par joueur + if player == 0: + if action_key == 'up': + pts = [(142, 345), (96, 394), (188, 394)] + else: + pts = [(142, 610), (96, 560), (188, 560)] + else: + if action_key == 'up': + pts = [(769, 345), (723, 394), (810, 394)] + else: + pts = [(769, 610), (723, 560), (810, 560)] + surf = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA) + pygame.draw.polygon(surf, color, pts) + self.screen.blit(surf, (0, 0)) + else: + overlay = pygame.Surface((r * 2, r * 2), pygame.SRCALPHA) + pygame.draw.circle(overlay, color, (r, r), r) + self.screen.blit(overlay, (cx - r, cy - r)) + + # Affichage de la vitesse dans le cadre noir entre les flèches + speed_boxes = [ + (115, 467, 171, 491), # joueur 1 + (737, 467, 796, 491), # joueur 2 + ] + for player in range(self.player_count): + x1, y1, x2, y2 = speed_boxes[player] + cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 + spd = self.speed_targets[player] + txt = f"{spd:+d}%" if spd != 0 else "0%" + col = GOOD if spd > 0 else (BAD if spd < 0 else TXT) + surf = self.small_font.render(txt, True, col) + r = surf.get_rect(center=(cx, cy)) + self.screen.blit(surf, r) - self._draw_text(self.screen, "ESC pour quitter", self.small_font, WARN, WIDTH - 170, HEIGHT - 35) + for player in range(self.player_count): + if self.bt_menu_open[player]: + self._draw_bt_menu_overlay(player) def _shutdown(self): for train in self.trains: diff --git a/tools/debug_joystick.py b/tools/debug_joystick.py new file mode 100644 index 0000000..57d888d --- /dev/null +++ b/tools/debug_joystick.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Outil de diagnostic pour analyser les entrées d'un joystick. +Affiche en temps réel les axes, boutons et chapeau détectés. +""" + +import os +import platform +import sys +import pygame +import time + +print("=== Infos système ===") +print(f" Python : {sys.version}") +print(f" OS : {platform.system()} {platform.release()}") + +# Même comportement que main.py : désactive HIDAPI pour éviter la renumérotation des boutons +os.environ.setdefault("SDL_JOYSTICK_HIDAPI", "0") +print(f" SDL_JOYSTICK_HIDAPI : {os.environ.get('SDL_JOYSTICK_HIDAPI', '(non défini)')}") + +def main(): + pygame.init() + pygame.joystick.init() + + sdl_ver = pygame.get_sdl_version() + print(f" pygame : {pygame.__version__}") + print(f" SDL : {sdl_ver[0]}.{sdl_ver[1]}.{sdl_ver[2]}") + print() + + # Petite fenêtre pour capturer les événements + screen = pygame.display.set_mode((400, 300)) + pygame.display.set_caption("Joystick Debug") + clock = pygame.time.Clock() + + joysticks = [] + for idx in range(pygame.joystick.get_count()): + joy = pygame.joystick.Joystick(idx) + joy.init() + joysticks.append(joy) + print(f"\n=== Joystick {idx}: {joy.get_name()} ===") + try: + print(f" GUID : {joy.get_guid()}") + except AttributeError: + pass + print(f" Boutons: {joy.get_numbuttons()}") + print(f" Axes : {joy.get_numaxes()}") + print(f" Chapeaux: {joy.get_numhats()}") + print(f" Trackballs: {joy.get_numballs()}") + + if not joysticks: + print("❌ Aucun joystick détecté!") + pygame.quit() + return + + print("\n📋 Monitoring en direct (appuyez sur ESC ou fermez la fenêtre pour quitter)...\n") + + axis_values = {} + button_states = {} + hat_states = {} + + running = True + while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: + running = False + elif event.type == pygame.JOYBUTTONDOWN: + print(f"🔴 [J{event.joy}] BOUTON DOWN: {event.button}") + elif event.type == pygame.JOYBUTTONUP: + print(f"⚪ [J{event.joy}] BOUTON UP: {event.button}") + elif event.type == pygame.JOYAXISMOTION: + # Afficher seulement si le mouvement est significatif + if abs(event.value) > 0.1: + print(f"↔️ [J{event.joy}] AXE {event.axis}: {event.value:.3f}") + elif event.type == pygame.JOYHATMOTION: + print(f"🧭 [J{event.joy}] CHAPEAU: {event.value}") + + # État en direct des axes + for idx, joy in enumerate(joysticks): + print("\r", end="") + status = f"[J{idx}] " + for axis_idx in range(min(4, joy.get_numaxes())): # Afficher les 4 premiers axes + val = joy.get_axis(axis_idx) + status += f"AX{axis_idx}:{val:6.3f} " + + # État du chapeau + try: + hat_x, hat_y = joy.get_hat(0) + status += f"| CHAPEAU:({hat_x},{hat_y})" + except: + pass + + print(status, end="", flush=True) + + clock.tick(30) # 30 FPS + + pygame.quit() + print("\n✅ Terminé.") + +if __name__ == "__main__": + main()