You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

1728 lines
71 KiB

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