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