commit c9203f5a3c7b55ffcb526d8c46bf716b22855c16 Author: scayac Date: Fri Feb 20 17:25:12 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d952a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Build and distribution artifacts +dist/ +build/ +*.egg-info/ + +# Tool caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# IDE/editor files +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..781a501 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# duploGame + +Jeu Pygame pour piloter 1 ou 2 trains LEGO DUPLO avec clavier PC ou contrôleurs arcade/joysticks. + +## Fonctionnalités + +- splash screen au démarrage +- menu de connexion de 1 ou 2 trains +- écran partagé en 2 zones de contrôle indépendantes +- support clavier (2 joueurs) + joysticks (1 joystick par joueur) +- intégration de `duploController` (asynchrone) pour piloter les trains + +## Dépendances + +- Python 3.10+ +- `pygame` +- `duploController` : https://gitea.christophe-scaya.fr/scayac/duploController + +## Installation + +Exemple: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install pygame +pip install git+https://gitea.christophe-scaya.fr/scayac/duploController.git +``` + +## Lancement + +```bash +python main.py +``` + +## Export Batocera (.pygame) + +Un script d'export est disponible pour générer un package `.pygame`: + +```bash +python tools/export_batocera_pygame.py --name duploGame --entrypoint main.py +``` + +Le fichier est généré dans `dist/duploGame.pygame`. +Par défaut (`--mode folder`), l'export génère le dossier `dist/duploGame/` au format +style Retrotrivia avec: + +- `dist/duploGame/duploGame.pygame` (script lanceur) +- les fichiers du jeu et assets à côté +- `vendor/` pour les modules Python embarqués + +Ce format est généralement le plus compatible avec Batocera. + +Le mode `single-file` reste disponible, mais moins recommandé: + +```bash +python tools/export_batocera_pygame.py --mode single-file +``` + +Dans ce mode, l'export génère `dist/duploGame.pygame` et un dossier payload `dist/duploGame/`. + +Par défaut, le script embarque les modules Python `duplo_controller`, `bleak` et `dbus_fast` dans `vendor/`. + +Options utiles: + +```bash +python tools/export_batocera_pygame.py \ + --project-dir . \ + --output-dir dist \ + --name duploGame \ + --entrypoint main.py \ + --mode folder \ + --vendor-module duplo_controller \ + --vendor-module bleak \ + --vendor-module dbus_fast +``` + +Si un module vendor n'est pas présent sur la machine de build: + +```bash +python tools/export_batocera_pygame.py --allow-missing-vendor +``` + +Ensuite, copier vers Batocera: + +- mode `folder`: copier le dossier `dist/duploGame/` dans `/userdata/roms/pygame` +- mode `single-file`: copier `dist/duploGame.pygame` **et** `dist/duploGame/` dans `/userdata/roms/pygame` + +## Contrôles + +### Menu + +- `UP` / `DOWN` (ou joystick) : choisir 1 ou 2 joueurs +- `ACTION 1` joueur 1 : connecter/déconnecter train 1 +- `ACTION 1` joueur 2 : connecter/déconnecter train 2 (mode 2 joueurs) +- `START` joueur 1 : lancer le mode contrôle (si trains requis connectés) +- `SELECT` joueur 1 (ou touche `C`) : ouvrir la configuration joystick +- `ACTION 2` joueur 1 (ou touche `K`) : ouvrir la configuration clavier + +### Configuration joystick + +- `LEFT/RIGHT` : sélectionner joueur 1 ou 2 +- `UP/DOWN` : sélectionner la fonction à mapper (`UP`, `DOWN`, `LEFT`, `RIGHT`, `A1..A6`, `SELECT`, `START`) +- `ACTION 1` : entrer en mode capture, puis appuyer sur le bouton physique à associer +- `SELECT` : reset mapping par défaut du joueur sélectionné +- `START` : retour menu + +Les mappings sont sauvegardés automatiquement dans `joystick_mappings.json` à la racine du projet, +et rechargés au prochain lancement du jeu. + +Important borne arcade: si votre encodeur expose le stick en boutons (et non en axes/hat), +mappez explicitement `UP`, `DOWN`, `LEFT`, `RIGHT` dans cet écran. + +### Configuration clavier + +- `LEFT/RIGHT` : sélectionner joueur 1 ou 2 +- `UP/DOWN` : sélectionner la fonction à mapper (`UP`, `DOWN`, `LEFT`, `RIGHT`, `A1..A6`, `SELECT`, `START`) +- `ACTION 1` : entrer en mode capture, puis appuyer sur la touche clavier à associer +- `SELECT` : reset mapping clavier par défaut du joueur sélectionné +- `START` : retour menu + +Les mappings clavier sont sauvegardés automatiquement dans `keyboard_mappings.json` à la racine du projet, +et rechargés au prochain lancement du jeu. + +### En jeu + +- `UP` / `DOWN` : accélérer / freiner +- `LEFT` / `RIGHT` : changer la couleur de la lumière +- `ACTION 1..5` : sons du train +- `ACTION 6` ou `SELECT` : arrêt immédiat +- `START` : retour menu +- `ESC` : quitter + +### Mappings clavier + +- Joueur 1: + - Directions: `W/Z`, `S`, `A/Q`, `D` + - Actions 1..6: `1 2 3 4 5 6` + - Start: `Entrée` + - Select: `Backspace` + +- Joueur 2: + - Directions: `↑ ↓ ← →` + - Actions 1..6: `U I O J K L` + - Start: `Shift droit` + - Select: `Ctrl droit` + +### Mappings joystick arcade (par défaut) + +- Directions: D-Pad (hat) ou axes gauche X/Y +- Actions 1..6: boutons `0..5` +- Select: bouton `6` +- Start: bouton `7` + +Ces mappings sont modifiables à chaud via l'écran de configuration joystick. + +> Si `duploController` n'est pas installé, le jeu démarre en mode simulation (sans contrôle BLE réel). diff --git a/img/train.png b/img/train.png new file mode 100644 index 0000000..9f208ef Binary files /dev/null and b/img/train.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..90aceb9 --- /dev/null +++ b/main.py @@ -0,0 +1,1240 @@ +import asyncio +import json +import threading +import time +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +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) + + +class GameState(Enum): + SPLASH = auto() + MENU = auto() + CONTROL = auto() + JOY_CONFIG = auto() + KEY_CONFIG = auto() + + +class AsyncLoopThread: + 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: + up: List[int] + down: List[int] + left: List[int] + right: List[int] + actions: List[int] + start: List[int] + select: List[int] + + +@dataclass +class PlayerInputState: + up: bool = False + down: 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: + 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 [ + ("UP", b.up[0]), + ("DOWN", b.down[0]), + ("LEFT", b.left[0]), + ("RIGHT", b.right[0]), + ("A1", b.actions[0]), + ("A2", b.actions[1]), + ("A3", b.actions[2]), + ("A4", b.actions[3]), + ("A5", b.actions[4]), + ("A6", b.actions[5]), + ("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): + 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] + 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: + joy_map = self.joy_bindings[player] + try: + hat_x, hat_y = joy.get_hat(0) + except Exception: + hat_x, hat_y = 0, 0 + + axis_x = joy.get_axis(0) if joy.get_numaxes() > 0 else 0 + axis_y = joy.get_axis(1) if joy.get_numaxes() > 1 else 0 + + s.up = s.up or hat_y > 0 or axis_y < -0.5 + s.down = s.down or hat_y < 0 or axis_y > 0.5 + s.left = s.left or hat_x < 0 or axis_x < -0.5 + s.right = s.right or hat_x > 0 or axis_x > 0.5 + + 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.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: + 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: + def __init__(self): + pygame.init() + pygame.display.set_caption("Duplo Arcade Controller") + self.screen = pygame.display.set_mode((WIDTH, HEIGHT)) + self.clock = pygame.time.Clock() + + self.title_font = pygame.font.SysFont("arial", 56, bold=True) + self.big_font = pygame.font.SysFont("arial", 36, bold=True) + self.font = pygame.font.SysFont("arial", 24) + self.small_font = pygame.font.SysFont("arial", 20) + + self.splash_image = self._load_splash_image() + + 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 = [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.scan_attempted = False + + self._load_joy_mappings() + self._load_key_mappings() + + self.colors = [] + if HAS_DUPLO_LIB: + self.colors = [ + 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() + self.input.update() + + 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): + elapsed = time.time() - self.splash_started + p0 = self.input.state(0) + p1 = self.input.state(1) + if elapsed >= SPLASH_DURATION or p0.start_pressed or p1.start_pressed or p0.actions_pressed[0]: + self.state = GameState.MENU + + def _update_menu(self): + p0 = self.input.state(0) + p1 = self.input.state(1) + + if not self.scan_attempted: + self._scan_available_trains() + + 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): + self.scan_attempted = True + if not HAS_BLEAK: + self.status_lines[0] = "BLE scanner indisponible (bleak introuvable)" + return + try: + fut = self.ble_loop.run(self._discover_duplo_trains_async()) + self.discovered_trains = fut.result(timeout=8.0) + self.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}" + + 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() + self.status_lines[player] = ("OK: " if ok else "KO: ") + msg + return + + if not self.discovered_trains: + self._scan_available_trains() + if not self.discovered_trains: + self.status_lines[player] = "KO: aucun train disponible" + 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() + 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() + else: + ok, msg = train.connect() + self.status_lines[player] = ("OK: " if ok else "KO: ") + msg + + def _sound_for_action(self, idx: int): + if not HAS_DUPLO_LIB: + return None + sounds = [ + DuploSound.HORN, + DuploSound.BRAKE, + DuploSound.STEAM, + DuploSound.STATION_DEPARTURE, + DuploSound.WATER_REFILL, + None, + ] + return sounds[idx] + + def _update_control(self): + now = time.time() + for player in range(self.player_count): + state = self.input.state(player) + train = self.trains[player] + + 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() + + if state.actions_pressed[5]: + self.speed_targets[player] = 0 + train.stop() + + for action_idx in range(5): + if state.actions_pressed[action_idx]: + sound = self._sound_for_action(action_idx) + if sound is not None: + train.play_sound(sound) + + if state.left_pressed and self.colors: + self.color_index[player] = (self.color_index[player] - 1) % len(self.colors) + train.set_light(self.colors[self.color_index[player]]) + if state.right_pressed and self.colors: + self.color_index[player] = (self.color_index[player] + 1) % len(self.colors) + train.set_light(self.colors[self.color_index[player]]) + + if state.select_pressed: + self.speed_targets[player] = 0 + train.stop() + + if state.start_pressed: + self.state = GameState.MENU + self.status_lines[player] = "retour menu" + + def _update_joy_config(self): + p0 = self.input.state(0) + if p0.start_pressed and not self.awaiting_joy_button: + self.state = GameState.MENU + self.config_message = "Retour menu" + return + + if not self.awaiting_joy_button: + if p0.left: + self.config_player = 0 + if p0.right: + self.config_player = 1 + if p0.up: + self.config_slot_idx = (self.config_slot_idx - 1) % len(self.config_slots) + if p0.down: + 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 + event_player = self.input.player_for_instance_id(event.instance_id) + 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.config_message = f"J{expected_player + 1} {slot} = bouton {event.button}" + break + + def _update_key_config(self): + p0 = self.input.state(0) + if p0.start_pressed and not self.awaiting_key_press: + self.state = GameState.MENU + self.key_config_message = "Retour menu" + return + + if not self.awaiting_key_press: + if p0.left: + self.key_config_player = 0 + if p0.right: + self.key_config_player = 1 + if p0.up: + self.key_config_slot_idx = (self.key_config_slot_idx - 1) % len(self.key_config_slots) + if p0.down: + 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): + image_path = Path("img") / "train.png" + if not image_path.exists(): + return None + try: + image = pygame.image.load(image_path.as_posix()).convert_alpha() + w, h = image.get_size() + max_w = int(WIDTH * 0.55) + max_h = int(HEIGHT * 0.45) + scale = min(max_w / w, max_h / h) + if scale < 1: + image = pygame.transform.smoothscale(image, (int(w * scale), int(h * scale))) + return image + except Exception: + return None + + def _draw_splash(self): + self.screen.fill(BG) + y_title = HEIGHT // 2 - 40 + if self.splash_image is not None: + img_rect = self.splash_image.get_rect(center=(WIDTH // 2, HEIGHT // 2 - 110)) + self.screen.blit(self.splash_image, img_rect) + y_title = HEIGHT // 2 + 120 + + self._draw_text(self.screen, "DUPLO ARCADE", self.title_font, TXT, WIDTH // 2, y_title, center=True) + self._draw_text( + self.screen, + "Contrôle clavier/joystick - Appuyer START ou attendre", + self.font, + WARN, + WIDTH // 2, + y_title + 64, + center=True, + ) + if not HAS_DUPLO_LIB: + self._draw_text( + self.screen, + "duploController non trouvé: mode simulation actif", + self.small_font, + BAD, + WIDTH // 2, + y_title + 110, + center=True, + ) + + 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) + self._draw_text(self.screen, "LEFT/RIGHT joueur: choisir train trouvé | ACTION 1: connecter", self.small_font, TXT, 40, 224) + + if self.discovered_trains: + preview = [] + 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 détecté pour ce joueur", self.small_font, BAD, x + 16, y + 46) + else: + name = joy.get_name() + self._draw_text(self.screen, f"Joystick: {name}", self.small_font, TXT, x + 16, y + 46) + + mapping = self.input.get_joy_bindings(player) + rows = [ + ("UP", mapping.get("up", -1)), + ("DOWN", mapping.get("down", -1)), + ("LEFT", mapping.get("left", -1)), + ("RIGHT", mapping.get("right", -1)), + ("A1", mapping["actions"][0]), + ("A2", mapping["actions"][1]), + ("A3", mapping["actions"][2]), + ("A4", mapping["actions"][3]), + ("A5", mapping["actions"][4]), + ("A6", mapping["actions"][5]), + ("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 = "désactivé" 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) + + def _draw_control(self): + self.screen.fill(BG) + panel_w = (WIDTH - 80 - 20) // 2 + + for i in [0, 1]: + x = 40 + i * (panel_w + 20) + y = 40 + h = HEIGHT - 80 + rect = pygame.Rect(x, y, panel_w, h) + pygame.draw.rect(self.screen, PANEL_BG, rect, border_radius=12) + pygame.draw.rect(self.screen, PANEL_BORDER, rect, 2, border_radius=12) + + active = i < self.player_count + title_color = TXT if active else (130, 130, 130) + self._draw_text(self.screen, f"Joueur {i + 1}", self.big_font, title_color, x + 20, y + 20) + + if not active: + self._draw_text(self.screen, "Zone inactive", self.font, WARN, x + 20, y + 90) + continue + + connected = self.trains[i].connected + self._draw_text( + self.screen, + f"Train: {'connecté' if connected else 'déconnecté'}", + self.font, + GOOD if connected else BAD, + x + 20, + y + 90, + ) + + speed = self.speed_targets[i] + self._draw_text(self.screen, f"Vitesse cible: {speed}", self.font, TXT, x + 20, y + 130) + bar_w = panel_w - 40 + bar_h = 28 + bar_x = x + 20 + bar_y = y + 170 + pygame.draw.rect(self.screen, (40, 51, 68), (bar_x, bar_y, bar_w, bar_h), border_radius=6) + center_x = bar_x + bar_w // 2 + pygame.draw.line(self.screen, (80, 95, 115), (center_x, bar_y), (center_x, bar_y + bar_h), 2) + fill = int((abs(speed) / 100.0) * (bar_w // 2)) + if speed >= 0: + pygame.draw.rect(self.screen, GOOD, (center_x, bar_y + 4, fill, bar_h - 8), border_radius=5) + else: + pygame.draw.rect(self.screen, BAD, (center_x - fill, bar_y + 4, fill, bar_h - 8), border_radius=5) + + self._draw_text(self.screen, "UP/DOWN: accélérer/freiner", self.small_font, TXT, x + 20, y + 240) + self._draw_text(self.screen, "LEFT/RIGHT: changer lumière", self.small_font, TXT, x + 20, y + 270) + self._draw_text(self.screen, "A1..A5: sons | A6/Select: stop", self.small_font, TXT, x + 20, y + 300) + self._draw_text(self.screen, "Start: retour menu", self.small_font, TXT, x + 20, y + 330) + + self._draw_text(self.screen, "ESC pour quitter", self.small_font, WARN, WIDTH - 170, HEIGHT - 35) + + 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..52190cb --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pygame>=2.5 \ No newline at end of file diff --git a/tools/export_batocera_pygame.py b/tools/export_batocera_pygame.py new file mode 100644 index 0000000..eb10a19 --- /dev/null +++ b/tools/export_batocera_pygame.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +import argparse +import importlib.util +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import List + + +DEFAULT_EXCLUDES = { + ".git", + ".venv", + "venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".idea", + ".vscode", + "dist", + "build", +} + +DEFAULT_EXT_EXCLUDES = { + ".pyc", + ".pyo", + ".pyd", + ".log", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Exporte un projet Pygame vers un package compatible Batocera" + ) + parser.add_argument("--project-dir", default=".", help="Répertoire du projet à exporter (défaut: .)") + parser.add_argument("--entrypoint", default="main.py", help="Script Python principal à lancer (défaut: main.py)") + parser.add_argument("--name", default="duploGame", help="Nom du jeu/package (défaut: duploGame)") + parser.add_argument("--output-dir", default="dist", help="Dossier de sortie (défaut: dist)") + parser.add_argument( + "--mode", + choices=["folder", "single-file"], + default="folder", + help="Format d'export: folder (style retrotrivia) ou single-file (défaut: folder)", + ) + parser.add_argument( + "--vendor-module", + action="append", + dest="vendor_modules", + default=None, + help="Module Python à embarquer (répétable). Par défaut: duplo_controller + bleak + dbus_fast", + ) + parser.add_argument( + "--allow-missing-vendor", + action="store_true", + help="N'échoue pas si un module vendor demandé est introuvable", + ) + return parser.parse_args() + + +def should_skip(path: Path, project_root: Path) -> bool: + rel = path.relative_to(project_root) + parts = set(rel.parts) + if parts & DEFAULT_EXCLUDES: + return True + if path.suffix.lower() in DEFAULT_EXT_EXCLUDES: + return True + return False + + +def copy_project(src_root: Path, dst_root: Path) -> None: + for root, dirs, files in os.walk(src_root): + root_path = Path(root) + dirs[:] = [d for d in dirs if not should_skip(root_path / d, src_root)] + + for filename in files: + src_file = root_path / filename + if should_skip(src_file, src_root): + continue + rel = src_file.relative_to(src_root) + dst_file = dst_root / rel + dst_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_file, dst_file) + + +def _copy_path(src: Path, dst: Path) -> None: + if src.is_dir(): + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + else: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + +def vendor_modules(package_root: Path, module_names: List[str], allow_missing: bool) -> List[str]: + vendor_root = package_root / "vendor" + vendor_root.mkdir(parents=True, exist_ok=True) + vendored = [] + + for module_name in module_names: + spec = importlib.util.find_spec(module_name) + if spec is None: + message = f"Module introuvable pour vendor: {module_name}" + if allow_missing: + print(f"[WARN] {message}") + continue + raise SystemExit(message) + + if spec.submodule_search_locations: + src_path = Path(list(spec.submodule_search_locations)[0]).resolve() + dst_path = vendor_root / src_path.name + _copy_path(src_path, dst_path) + elif spec.origin: + src_file = Path(spec.origin).resolve() + dst_file = vendor_root / src_file.name + _copy_path(src_file, dst_file) + else: + message = f"Impossible de résoudre la source du module: {module_name}" + if allow_missing: + print(f"[WARN] {message}") + continue + raise SystemExit(message) + + vendored.append(module_name) + + return vendored + + +def build_folder_launcher(entrypoint: str) -> str: + return f'''#!/usr/bin/env python +# Auto-generated Batocera .pygame launcher (folder mode) +import os +import runpy +import sys +from pathlib import Path + + +def main(): + game_dir = Path(__file__).resolve().parent + vendor_dir = game_dir / "vendor" + if vendor_dir.exists(): + sys.path.insert(0, str(vendor_dir)) + sys.path.insert(0, str(game_dir)) + os.chdir(game_dir) + os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") + + entry = game_dir / {entrypoint!r} + if not entry.exists(): + raise SystemExit(f"Entrypoint introuvable: {{entry}}") + + runpy.run_path(str(entry), run_name="__main__") + + +if __name__ == "__main__": + main() +''' + + +def build_single_file_launcher(game_name: str, entrypoint: str) -> str: + return f'''#!/usr/bin/env python +# Auto-generated Batocera .pygame launcher (single-file mode) +import os +import runpy +import shutil +import sys +import tempfile +from pathlib import Path + + +def main(): + script_path = Path(__file__).resolve() + payload_dir = script_path.with_suffix("") + if not payload_dir.exists(): + raise SystemExit(f"Dossier payload introuvable: {{payload_dir}}") + + root = Path(tempfile.gettempdir()) / {('batocera_pygame_' + game_name)!r} + if root.exists(): + shutil.rmtree(root, ignore_errors=True) + shutil.copytree(payload_dir, root) + + vendor_dir = root / "vendor" + if vendor_dir.exists(): + sys.path.insert(0, str(vendor_dir)) + sys.path.insert(0, str(root)) + os.chdir(root) + os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") + + entry = root / {entrypoint!r} + if not entry.exists(): + raise SystemExit(f"Entrypoint introuvable: {{entry}}") + + runpy.run_path(str(entry), run_name="__main__") + + +if __name__ == "__main__": + main() +''' + + +def make_executable(path: Path) -> None: + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def main() -> None: + args = parse_args() + project_dir = Path(args.project_dir).resolve() + entrypoint = args.entrypoint + game_name = args.name + output_dir = Path(args.output_dir).resolve() + + if not project_dir.exists(): + raise SystemExit(f"Projet introuvable: {project_dir}") + if not (project_dir / entrypoint).exists(): + raise SystemExit(f"Entrypoint introuvable: {entrypoint}") + + vendor_modules_list = args.vendor_modules or ["duplo_controller", "bleak", "dbus_fast"] + output_path = output_dir / f"{game_name}.pygame" + + with tempfile.TemporaryDirectory(prefix="batocera_pygame_export_") as tmp: + temp_root = Path(tmp) + package_root = temp_root / game_name + package_root.mkdir(parents=True, exist_ok=True) + + copy_project(project_dir, package_root) + vendored = vendor_modules( + package_root, + module_names=vendor_modules_list, + allow_missing=args.allow_missing_vendor, + ) + + output_dir.mkdir(parents=True, exist_ok=True) + + if args.mode == "folder": + game_output_dir = output_dir / game_name + if game_output_dir.exists(): + shutil.rmtree(game_output_dir) + shutil.copytree(package_root, game_output_dir) + + launcher_path = game_output_dir / f"{game_name}.pygame" + launcher_path.write_text(build_folder_launcher(entrypoint=entrypoint), encoding="utf-8") + make_executable(launcher_path) + output_path = launcher_path + else: + output_path.write_text(build_single_file_launcher(game_name=game_name, entrypoint=entrypoint), encoding="utf-8") + make_executable(output_path) + + payload_dir = output_dir / game_name + if payload_dir.exists(): + shutil.rmtree(payload_dir) + shutil.copytree(package_root, payload_dir) + + print(f"Export termine: {output_path}") + if vendored: + print(f"Modules vendor inclus: {', '.join(vendored)}") + + +if __name__ == "__main__": + main()