"""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 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: from duplo_controller import DuploColor, DuploSound, DuploTrainHub HAS_DUPLO_LIB = True except Exception: HAS_DUPLO_LIB = False DuploTrainHub = None DuploColor = None DuploSound = None try: from bleak import BleakScanner HAS_BLEAK = True except Exception: HAS_BLEAK = False BleakScanner = None WIDTH, HEIGHT = 1280, 720 FPS = 60 SPLASH_DURATION = 2.5 BG = (14, 18, 26) PANEL_BG = (25, 33, 46) PANEL_BORDER = (59, 76, 103) TXT = (235, 240, 245) 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() JOY_CONFIG = auto() KEY_CONFIG = auto() 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) self._thread.start() def _run(self): asyncio.set_event_loop(self._loop) self._loop.run_forever() def run(self, coro): return asyncio.run_coroutine_threadsafe(coro, self._loop) def stop(self): self._loop.call_soon_threadsafe(self._loop.stop) self._thread.join(timeout=1.0) @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] right: List[int] actions: List[int] start: List[int] select: List[int] @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 right_pressed: bool = False actions_down: List[bool] = None actions_pressed: List[bool] = None start_down: bool = False start_pressed: bool = False select_down: bool = False select_pressed: bool = False def __post_init__(self): if self.actions_down is None: self.actions_down = [False] * 6 if self.actions_pressed is None: self.actions_pressed = [False] * 6 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( up=[pygame.K_UP], down=[pygame.K_DOWN], left=[pygame.K_LEFT], right=[pygame.K_RIGHT], actions=[pygame.K_u, pygame.K_i, pygame.K_o, pygame.K_j, pygame.K_k, pygame.K_l], start=[pygame.K_RSHIFT], select=[pygame.K_RCTRL], ), 1: PlayerBindings( up=[pygame.K_z], down=[pygame.K_s], left=[pygame.K_q], right=[pygame.K_d], actions=[pygame.K_r, pygame.K_t, pygame.K_y, pygame.K_f, pygame.K_g, pygame.K_h], start=[pygame.K_RETURN], select=[pygame.K_BACKSPACE], ) , } self.default_bindings = { 0: PlayerBindings( up=[pygame.K_UP], down=[pygame.K_DOWN], left=[pygame.K_LEFT], right=[pygame.K_RIGHT], actions=[pygame.K_u, pygame.K_i, pygame.K_o, pygame.K_j, pygame.K_k, pygame.K_l], start=[pygame.K_RSHIFT], select=[pygame.K_RCTRL], ), 1: PlayerBindings( up=[pygame.K_z], down=[pygame.K_s], left=[pygame.K_q], right=[pygame.K_d], actions=[pygame.K_r, pygame.K_t, pygame.K_y, pygame.K_f, pygame.K_g, pygame.K_h], start=[pygame.K_RETURN], select=[pygame.K_BACKSPACE], ) } self.prev: Dict[int, PlayerInputState] = {0: PlayerInputState(), 1: PlayerInputState()} self.states: Dict[int, PlayerInputState] = {0: PlayerInputState(), 1: PlayerInputState()} self.joy_bindings = { 0: {"actions": [0, 1, 2, 3, 4, 5], "select": 6, "start": 7, "up": -1, "down": -1, "left": -1, "right": -1}, 1: {"actions": [0, 1, 2, 3, 4, 5], "select": 6, "start": 7, "up": -1, "down": -1, "left": -1, "right": -1}, } pygame.joystick.init() self.joysticks = [] self.refresh_joysticks() def refresh_joysticks(self): self.joysticks = [] for idx in range(pygame.joystick.get_count()): joy = pygame.joystick.Joystick(idx) joy.init() self.joysticks.append(joy) def _joy_for_player(self, player_idx: int): return self.joysticks[player_idx] if player_idx < len(self.joysticks) else None def player_for_instance_id(self, instance_id: int) -> Optional[int]: for player, joy in enumerate(self.joysticks): if joy.get_instance_id() == instance_id: return player return None def set_joy_binding(self, player: int, slot: str, button: int): if player not in self.joy_bindings: return if slot.startswith("A"): idx = int(slot[1:]) - 1 if 0 <= idx < 6: self.joy_bindings[player]["actions"][idx] = button elif slot == "START": self.joy_bindings[player]["start"] = button elif slot == "SELECT": self.joy_bindings[player]["select"] = button elif slot in ("UP", "DOWN", "LEFT", "RIGHT"): self.joy_bindings[player][slot.lower()] = button def reset_joy_bindings(self, player: int): self.joy_bindings[player] = { "actions": [0, 1, 2, 3, 4, 5], "select": 6, "start": 7, "up": -1, "down": -1, "left": -1, "right": -1, } def get_joy_bindings(self, player: int): return self.joy_bindings[player] def set_key_binding(self, player: int, slot: str, key: int): if player not in self.bindings: return b = self.bindings[player] if slot == "UP": b.up = [key] elif slot == "DOWN": b.down = [key] elif slot == "LEFT": b.left = [key] elif slot == "RIGHT": b.right = [key] elif slot.startswith("A"): idx = int(slot[1:]) - 1 if 0 <= idx < 6: b.actions[idx] = key elif slot == "START": b.start = [key] elif slot == "SELECT": b.select = [key] def reset_key_bindings(self, player: int): d = self.default_bindings[player] self.bindings[player] = PlayerBindings( up=list(d.up), down=list(d.down), left=list(d.left), right=list(d.right), actions=list(d.actions), start=list(d.start), select=list(d.select), ) def get_key_binding_rows(self, player: int): b = self.bindings[player] return [ ("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]), ] def export_joy_bindings(self): return { "player1": self.joy_bindings[0], "player2": self.joy_bindings[1], } def export_key_bindings(self): out = {} for player, key in [(0, "player1"), (1, "player2")]: b = self.bindings[player] out[key] = { "up": b.up[0], "down": b.down[0], "left": b.left[0], "right": b.right[0], "actions": list(b.actions), "start": b.start[0], "select": b.select[0], } return out def import_joy_bindings(self, data: dict): for player, key in [(0, "player1"), (1, "player2")]: raw = data.get(key) if not isinstance(raw, dict): continue actions = raw.get("actions", []) if not isinstance(actions, list) or len(actions) != 6: continue normalized_actions = [] valid = True for b in actions: if not isinstance(b, int) or b < 0: valid = False break normalized_actions.append(b) if not valid: continue start = raw.get("start") select = raw.get("select") if not isinstance(start, int) or start < 0: continue if not isinstance(select, int) or select < 0: continue up_btn = raw.get("up", -1) down_btn = raw.get("down", -1) left_btn = raw.get("left", -1) right_btn = raw.get("right", -1) directions_are_valid = True for v in [up_btn, down_btn, left_btn, right_btn]: if not isinstance(v, int) or v < -1: directions_are_valid = False break if not directions_are_valid: continue self.joy_bindings[player] = { "actions": normalized_actions, "start": start, "select": select, "up": up_btn, "down": down_btn, "left": left_btn, "right": right_btn, } def import_key_bindings(self, data: dict): for player, key in [(0, "player1"), (1, "player2")]: raw = data.get(key) if not isinstance(raw, dict): continue actions = raw.get("actions") if not isinstance(actions, list) or len(actions) != 6: continue required = ["up", "down", "left", "right", "start", "select"] if any(not isinstance(raw.get(name), int) or raw.get(name) < 0 for name in required): continue if any(not isinstance(a, int) or a < 0 for a in actions): continue self.bindings[player] = PlayerBindings( up=[raw["up"]], down=[raw["down"]], left=[raw["left"]], right=[raw["right"]], actions=list(actions), start=[raw["start"]], select=[raw["select"]], ) @staticmethod def _key_any_pressed(keys, keycodes): return any(keys[k] for k in keycodes) 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()} for player in [0, 1]: b = self.bindings[player] s = self.states[player] # 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 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 # 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) left_btn = joy_map.get("left", -1) right_btn = joy_map.get("right", -1) if up_btn >= 0 and up_btn < joy.get_numbuttons() and joy.get_button(up_btn): s.up = True if down_btn >= 0 and down_btn < joy.get_numbuttons() and joy.get_button(down_btn): s.down = True if left_btn >= 0 and left_btn < joy.get_numbuttons() and joy.get_button(left_btn): s.left = True if right_btn >= 0 and right_btn < joy.get_numbuttons() and joy.get_button(right_btn): s.right = True for i, btn in enumerate(joy_map["actions"]): if btn < joy.get_numbuttons() and joy.get_button(btn): s.actions_down[i] = True start_btn = joy_map["start"] select_btn = joy_map["select"] if start_btn < joy.get_numbuttons() and joy.get_button(start_btn): s.start_down = True if select_btn < joy.get_numbuttons() and joy.get_button(select_btn): 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)] s.start_pressed = s.start_down and not prev.start_down s.select_pressed = s.select_down and not prev.select_down def state(self, player_idx: int) -> PlayerInputState: return self.states[player_idx] 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 self.connected = False self.last_speed = 0 self.last_sent = 0.0 self.pending_speed: Optional[int] = None self.min_command_interval = 0.12 self._hub = DuploTrainHub(address=address) if HAS_DUPLO_LIB else None def set_address(self, address: Optional[str]): self.address = address if HAS_DUPLO_LIB: self._hub = DuploTrainHub(address=address) def _submit(self, coro): if not HAS_DUPLO_LIB: return None return self.loop_thread.run(coro) def connect(self, timeout=10.0) -> Tuple[bool, str]: if self.connected: return True, "déjà connecté" if not HAS_DUPLO_LIB: self.connected = True return True, "simulation (duploController non trouvé)" try: fut = self._submit(self._hub.connect()) fut.result(timeout=timeout) self.connected = True return True, "connecté" except Exception as exc: return False, f"erreur connexion: {exc}" def disconnect(self, timeout=5.0) -> Tuple[bool, str]: if not self.connected: return True, "déjà déconnecté" if not HAS_DUPLO_LIB: self.connected = False return True, "simulation déconnectée" try: fut = self._submit(self._hub.disconnect()) fut.result(timeout=timeout) self.connected = False self.last_speed = 0 return True, "déconnecté" except Exception as exc: return False, f"erreur déconnexion: {exc}" def set_speed(self, speed: int): speed = max(-100, min(100, speed)) if speed == self.last_speed and self.pending_speed is None: return self.pending_speed = speed self.flush_speed() def flush_speed(self): if self.pending_speed is None: return now = time.time() if now - self.last_sent < self.min_command_interval: return speed = self.pending_speed self.pending_speed = None self.last_speed = speed self.last_sent = now if not self.connected: return if not HAS_DUPLO_LIB: return self._submit(self._hub.set_motor_speed(speed)) def stop(self): self.pending_speed = None self.last_speed = 0 self.last_sent = time.time() if not self.connected: return if not HAS_DUPLO_LIB: return self._submit(self._hub.stop()) def play_sound(self, sound): if not self.connected or not HAS_DUPLO_LIB: return self._submit(self._hub.play_sound(sound)) def set_light(self, color): if not self.connected or not HAS_DUPLO_LIB: return self._submit(self._hub.change_light_color(color)) 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), pygame.FULLSCREEN | pygame.SCALED) self.clock = pygame.time.Clock() _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() self.running = True self.frame_events = [] self.input = InputManager() self.joy_config_path = Path("joystick_mappings.json") self.key_config_path = Path("keyboard_mappings.json") self.ble_loop = AsyncLoopThread() self.player_count = 1 self.trains = [TrainProxy(self.ble_loop), TrainProxy(self.ble_loop)] self.addresses = ["", ""] self.status_lines = ["appuyer ACTION 1 pour connecter", "disponible si mode 2 joueurs"] self.speed_targets = [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] self.config_player = 0 self.config_slot_idx = 0 self.config_slots = ["UP", "DOWN", "LEFT", "RIGHT", "A1", "A2", "A3", "A4", "A5", "A6", "SELECT", "START"] self.awaiting_joy_button = False self.config_message = "ACTION 1 pour modifier, SELECT pour reset joueur, START pour retour menu" self.key_config_player = 0 self.key_config_slot_idx = 0 self.key_config_slots = ["UP", "DOWN", "LEFT", "RIGHT", "A1", "A2", "A3", "A4", "A5", "A6", "SELECT", "START"] self.awaiting_key_press = False self.key_config_message = "ACTION 1 pour modifier, SELECT pour reset joueur, START pour retour menu" 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.duplo_colors = [] if HAS_DUPLO_LIB: self.duplo_colors = [ DuploColor.BLACK, DuploColor.PINK, DuploColor.PURPLE, DuploColor.BLUE, DuploColor.LIGHTBLUE, DuploColor.CYAN, DuploColor.GREEN, DuploColor.YELLOW, DuploColor.ORANGE, DuploColor.RED, DuploColor.WHITE, ] def run(self): while self.running: self._handle_events() # 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() self._draw_splash() elif self.state == GameState.MENU: self._update_menu() self._draw_menu() elif self.state == GameState.CONTROL: self._update_control() self._draw_control() elif self.state == GameState.JOY_CONFIG: self._update_joy_config() self._draw_joy_config() elif self.state == GameState.KEY_CONFIG: self._update_key_config() self._draw_key_config() pygame.display.flip() self.clock.tick(FPS) self._shutdown() def _handle_events(self): self.frame_events = pygame.event.get() for event in self.frame_events: if event.type == pygame.QUIT: self.running = False elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: self.running = False elif event.type == pygame.KEYDOWN and event.key == pygame.K_c and self.state == GameState.MENU: self.state = GameState.JOY_CONFIG self.awaiting_joy_button = False self.config_message = "Config joystick: ACTION 1 pour modifier" elif event.type == pygame.KEYDOWN and event.key == pygame.K_k and self.state == GameState.MENU: self.state = GameState.KEY_CONFIG self.awaiting_key_press = False self.key_config_message = "Config clavier: ACTION 1 pour modifier" elif event.type in (pygame.JOYDEVICEADDED, pygame.JOYDEVICEREMOVED): self.input.refresh_joysticks() def _update_splash(self): p0 = self.input.state(0) p1 = self.input.state(1) # 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) p1 = self.input.state(1) 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 if any_up: self.player_count = 1 if any_down: self.player_count = 2 if p0.left_pressed: self._cycle_selected_train(0, -1) if p0.right_pressed: self._cycle_selected_train(0, +1) if self.player_count == 2 and p1.left_pressed: self._cycle_selected_train(1, -1) if self.player_count == 2 and p1.right_pressed: self._cycle_selected_train(1, +1) if p0.actions_pressed[0]: self._connect_selected_or_toggle(0) if self.player_count == 2 and p1.actions_pressed[0]: self._connect_selected_or_toggle(1) if p0.start_pressed: required = 1 if self.player_count == 1 else 2 connected = sum(1 for i in range(self.player_count) if self.trains[i].connected) if connected >= required: self.state = GameState.CONTROL self.status_lines[0] = "contrôle actif" if self.player_count == 2: self.status_lines[1] = "contrôle actif" else: self.status_lines[0] = "connectez les trains requis avant START" if p0.select_pressed: self.state = GameState.JOY_CONFIG self.awaiting_joy_button = False self.config_message = "Config joystick: ACTION 1 pour modifier" if p0.actions_pressed[1]: self.state = GameState.KEY_CONFIG self.awaiting_key_press = False self.key_config_message = "Config clavier: ACTION 1 pour modifier" async def _discover_duplo_trains_async(self) -> List[Dict[str, object]]: if not HAS_BLEAK: return [] found = await BleakScanner.discover(timeout=4.0, return_adv=True) trains = [] for _, payload in found.items(): device, advertisement = payload manufacturer_data = getattr(advertisement, "manufacturer_data", {}) or {} lego_data = manufacturer_data.get(0x0397) if not isinstance(lego_data, (bytes, bytearray)): continue is_duplo = False if len(lego_data) >= 2 and lego_data[1] == 0x20: is_duplo = True elif len(lego_data) >= 1 and lego_data[0] == 0x20: is_duplo = True if not is_duplo: continue name = device.name or getattr(advertisement, "local_name", None) or "DUPLO Hub" trains.append( { "address": device.address, "name": name, "rssi": getattr(device, "rssi", None), } ) uniq = {} for train in trains: uniq[train["address"]] = train result = list(uniq.values()) result.sort(key=lambda item: item.get("rssi") if isinstance(item.get("rssi"), int) else -999, reverse=True) 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: 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)" if self.player_count == 2 and len(self.discovered_trains) > 1: self.selected_train_index[1] = 1 else: self.status_lines[0] = "aucun train trouvé (vérifier alimentation/BLE)" 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: return count = len(self.discovered_trains) self.selected_train_index[player] = (self.selected_train_index[player] + delta) % count def _selected_train_for_player(self, player: int) -> Optional[Dict[str, object]]: if not self.discovered_trains: return None idx = self.selected_train_index[player] % len(self.discovered_trains) return self.discovered_trains[idx] def _connect_selected_or_toggle(self, player: int): if player == 1 and self.player_count == 1: return 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: 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: self.status_lines[player] = "KO: sélection invalide" return chosen_addr = chosen["address"] other = 1 - player if self.trains[other].connected and self.trains[other].address == chosen_addr: self.status_lines[player] = "KO: ce train est déjà utilisé par l'autre joueur" return 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}" def _toggle_connect(self, player: int): if player == 1 and self.player_count == 1: return 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 _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 [] return [ DuploSound.BRAKE, DuploSound.STATION_DEPARTURE, DuploSound.WATER_REFILL, DuploSound.HORN, DuploSound.STEAM, ] 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 if state.down: delta -= 2 if delta and now - self.last_speed_adjust[player] >= self.speed_adjust_interval: step = self.speed_adjust_step if delta > 0 else -self.speed_adjust_step self.speed_targets[player] = max(-100, min(100, self.speed_targets[player] + step)) train.set_speed(self.speed_targets[player]) self.last_speed_adjust[player] = now train.flush_speed() # a4 : STOP if state.actions_pressed[4]: self.speed_targets[player] = 0 train.stop() # a5 : changement de sens if state.actions_pressed[5]: self.speed_targets[player] = -self.speed_targets[player] train.set_speed(self.speed_targets[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: 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: 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 = self._config_return_state self.config_message = "Retour menu" return if not self.awaiting_joy_button: if p0.left_pressed: self.config_player = 0 if p0.right_pressed: self.config_player = 1 if p0.up_pressed: self.config_slot_idx = (self.config_slot_idx - 1) % len(self.config_slots) 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 self.config_message = f"J{self.config_player + 1} {self.config_slots[self.config_slot_idx]}: appuyez sur un bouton joystick" elif p0.select_pressed: self.input.reset_joy_bindings(self.config_player) self._save_joy_mappings() self.config_message = f"J{self.config_player + 1}: mapping joystick remis par défaut" return if p0.select_pressed: self.awaiting_joy_button = False self.config_message = "Capture annulée" return expected_player = self.config_player slot = self.config_slots[self.config_slot_idx] for event in self.frame_events: if event.type != pygame.JOYBUTTONDOWN: continue # 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: self.config_message = f"Bouton ignoré: joystick joueur {event_player + 1}, attendu joueur {expected_player + 1}" continue 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 = self._config_return_state self.key_config_message = "Retour menu" return if not self.awaiting_key_press: if p0.left_pressed: self.key_config_player = 0 if p0.right_pressed: self.key_config_player = 1 if p0.up_pressed: self.key_config_slot_idx = (self.key_config_slot_idx - 1) % len(self.key_config_slots) 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 slot = self.key_config_slots[self.key_config_slot_idx] self.key_config_message = f"J{self.key_config_player + 1} {slot}: appuyez sur une touche clavier" elif p0.select_pressed: self.input.reset_key_bindings(self.key_config_player) self._save_key_mappings() self.key_config_message = f"J{self.key_config_player + 1}: mapping clavier remis par défaut" return if p0.select_pressed: self.awaiting_key_press = False self.key_config_message = "Capture annulée" return expected_player = self.key_config_player slot = self.key_config_slots[self.key_config_slot_idx] for event in self.frame_events: if event.type != pygame.KEYDOWN: continue if event.key == pygame.K_ESCAPE: continue self.input.set_key_binding(expected_player, slot, event.key) self._save_key_mappings() self.awaiting_key_press = False key_name = pygame.key.name(event.key) self.key_config_message = f"J{expected_player + 1} {slot} = {key_name}" break def _save_joy_mappings(self): try: payload = self.input.export_joy_bindings() with self.joy_config_path.open("w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2) except Exception as exc: self.config_message = f"Erreur sauvegarde mappings: {exc}" def _load_joy_mappings(self): if not self.joy_config_path.exists(): return try: with self.joy_config_path.open("r", encoding="utf-8") as handle: data = json.load(handle) if isinstance(data, dict): self.input.import_joy_bindings(data) self.config_message = "Mappings joystick chargés depuis joystick_mappings.json" except Exception as exc: self.config_message = f"Erreur chargement mappings: {exc}" def _save_key_mappings(self): try: payload = self.input.export_key_bindings() with self.key_config_path.open("w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2) except Exception as exc: self.key_config_message = f"Erreur sauvegarde mappings clavier: {exc}" def _load_key_mappings(self): if not self.key_config_path.exists(): return try: with self.key_config_path.open("r", encoding="utf-8") as handle: data = json.load(handle) if isinstance(data, dict): self.input.import_key_bindings(data) self.key_config_message = "Mappings clavier chargés depuis keyboard_mappings.json" except Exception as exc: self.key_config_message = f"Erreur chargement mappings clavier: {exc}" def _draw_text(self, surface, text, font, color, x, y, center=False): s = font.render(text, True, color) r = s.get_rect() if center: r.center = (x, y) else: r.topleft = (x, y) surface.blit(s, r) def _load_splash_image(self): 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() return pygame.transform.smoothscale(image, (WIDTH, HEIGHT)) except Exception: return None def _draw_splash(self): if self.splash_image is not None: 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) self._draw_text(self.screen, "Connexion des trains", self.big_font, TXT, 40, 24) self._draw_text(self.screen, "UP=1 joueur | DOWN=2 joueurs", self.font, WARN, 40, 76) self._draw_text(self.screen, f"Mode actuel: {self.player_count} joueur(s)", self.font, TXT, 40, 110) self._draw_text( self.screen, "ACTION 1 sur chaque joueur: connecter/déconnecter | START P1: lancer", self.small_font, TXT, 40, 146, ) 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) if self.discovered_trains: preview = [] for idx, train in enumerate(self.discovered_trains[:3]): mark = "*" if idx in [self.selected_train_index[0], self.selected_train_index[1]] else " " name = train.get("name", "DUPLO Hub") addr = train.get("address", "?") preview.append(f"{mark}{idx + 1}: {name} [{addr}]") self._draw_text(self.screen, " | ".join(preview), self.small_font, GOOD, 40, 252) else: self._draw_text(self.screen, "Scan BLE en cours ou aucun train trouvé", self.small_font, BAD, 40, 252) panel_w = (WIDTH - 80 - 20) // 2 for i in [0, 1]: x = 40 + i * (panel_w + 20) y = 280 active = i < self.player_count rect = pygame.Rect(x, y, panel_w, 300) pygame.draw.rect(self.screen, PANEL_BG, rect, border_radius=10) pygame.draw.rect(self.screen, PANEL_BORDER, rect, 2, border_radius=10) title = f"Train Joueur {i + 1}" self._draw_text(self.screen, title, self.font, TXT if active else (130, 130, 130), x + 16, y + 16) if not active: self._draw_text(self.screen, "Inactif (mode 1 joueur)", self.small_font, WARN, x + 16, y + 58) continue status = "Connecté" if self.trains[i].connected else "Déconnecté" status_color = GOOD if self.trains[i].connected else BAD self._draw_text(self.screen, f"État: {status}", self.font, status_color, x + 16, y + 58) self._draw_text(self.screen, self.status_lines[i], self.small_font, TXT, x + 16, y + 98) chosen = self._selected_train_for_player(i) if chosen is not None: chosen_name = chosen.get("name", "DUPLO Hub") chosen_addr = chosen.get("address", "?") self._draw_text(self.screen, f"Sélection: {chosen_name}", self.small_font, WARN, x + 16, y + 126) self._draw_text(self.screen, str(chosen_addr), self.small_font, WARN, x + 16, y + 146) else: self._draw_text(self.screen, "Sélection: aucun train", self.small_font, WARN, x + 16, y + 126) ctrl_help = [ "Directions: vitesse (+/-) et lumière (gauche/droite)", "Action1..5: sons train", "Action6 / Select: stop immédiat", "Start: retour menu en jeu", ] for n, line in enumerate(ctrl_help): self._draw_text(self.screen, line, self.small_font, (190, 203, 219), x + 16, y + 178 + 24 * n) def _draw_joy_config(self): self.screen.fill(BG) self._draw_text(self.screen, "Configuration Joystick", self.big_font, TXT, 40, 24) self._draw_text(self.screen, "LEFT/RIGHT: joueur | UP/DOWN: fonction | ACTION 1: remapper", self.small_font, WARN, 40, 78) self._draw_text(self.screen, "SELECT: reset joueur | START: retour menu", self.small_font, WARN, 40, 104) panel_w = (WIDTH - 80 - 20) // 2 for player in [0, 1]: x = 40 + player * (panel_w + 20) y = 150 rect = pygame.Rect(x, y, panel_w, 460) pygame.draw.rect(self.screen, PANEL_BG, rect, border_radius=12) pygame.draw.rect(self.screen, PANEL_BORDER, rect, 2, border_radius=12) selected_player = player == self.config_player color = GOOD if selected_player else TXT self._draw_text(self.screen, f"Joueur {player + 1}", self.font, color, x + 16, y + 14) joy = self.input._joy_for_player(player) if joy is None: 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 = [ ("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"]), ] for idx, (slot, button) in enumerate(rows): line_y = y + 80 + idx * 30 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 = "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) msg_color = BAD if self.awaiting_joy_button else TXT self._draw_text(self.screen, self.config_message, self.small_font, msg_color, 40, HEIGHT - 46) def _draw_key_config(self): self.screen.fill(BG) self._draw_text(self.screen, "Configuration Clavier", self.big_font, TXT, 40, 24) self._draw_text(self.screen, "LEFT/RIGHT: joueur | UP/DOWN: fonction | ACTION 1: remapper", self.small_font, WARN, 40, 78) self._draw_text(self.screen, "SELECT: reset joueur | START: retour menu", self.small_font, WARN, 40, 104) panel_w = (WIDTH - 80 - 20) // 2 for player in [0, 1]: x = 40 + player * (panel_w + 20) y = 150 rect = pygame.Rect(x, y, panel_w, 460) pygame.draw.rect(self.screen, PANEL_BG, rect, border_radius=12) pygame.draw.rect(self.screen, PANEL_BORDER, rect, 2, border_radius=12) selected_player = player == self.key_config_player color = GOOD if selected_player else TXT self._draw_text(self.screen, f"Joueur {player + 1}", self.font, color, x + 16, y + 14) rows = self.input.get_key_binding_rows(player) for idx, (slot, key_value) in enumerate(rows): line_y = y + 60 + idx * 32 is_selected_slot = selected_player and idx == self.key_config_slot_idx line_color = WARN if is_selected_slot else TXT prefix = "> " if is_selected_slot else " " key_name = pygame.key.name(key_value) self._draw_text(self.screen, f"{prefix}{slot}", self.small_font, line_color, x + 16, line_y) self._draw_text(self.screen, key_name, self.small_font, line_color, x + 170, line_y) 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) @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) 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) 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) 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: train.disconnect(timeout=2.0) self.ble_loop.stop() pygame.quit() if __name__ == "__main__": game = DuploGame() game.run()