commit
c9203f5a3c
6 changed files with 1690 additions and 0 deletions
@ -0,0 +1,31 @@
@@ -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 @@
@@ -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 @@
@@ -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