commit
c9203f5a3c
6 changed files with 1690 additions and 0 deletions
@ -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 |
||||||
@ -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). |
||||||
|
After Width: | Height: | Size: 1.1 MiB |
@ -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() |
||||||
Loading…
Reference in new issue