Browse Source

Initial commit

master
scayac 4 weeks ago
commit
c9203f5a3c
  1. 31
      .gitignore
  2. 157
      README.md
  3. BIN
      img/train.png
  4. 1240
      main.py
  5. 1
      requirements.txt
  6. 261
      tools/export_batocera_pygame.py

31
.gitignore vendored

@ -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

157
README.md

@ -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).

BIN
img/train.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

1240
main.py

File diff suppressed because it is too large Load Diff

1
requirements.txt

@ -0,0 +1 @@ @@ -0,0 +1 @@
pygame>=2.5

261
tools/export_batocera_pygame.py

@ -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…
Cancel
Save