Controller-Fix: Joystick-Objekte wurden nach dem Namens-Auslesen freigegeben, dadurch lieferte SDL keine Events mehr. Jetzt offen halten + Hotplug. Neu: tools/controller_check.py zur Diagnose, Tests fuer Tastenbelegung (62).

This commit is contained in:
Project Bridge committed 2026-11-01 13:10:43 +00:00
1 parent a088c23a09
commit 5237b0a39c
7 files changed
+343 -17

No files matched your search

+24 -1
View File
@@ -153,7 +153,30 @@ Spiel läuft dann mit der Tastatur weiter.
* **Bluetooth:** Systemeinstellungen → Bluetooth → Controller koppeln. Geht,
ist am Stand aber störanfälliger (Akku, Reconnects).
* **Microsofts Xbox-Funk-Dongle** funktioniert auf macOS **nicht**.
* Der Name des erkannten Controllers steht nach dem Start im Terminal.
* Der Controller muss vor dem Spielstart verbunden sein. Wird er später
verbunden, erkennt das Spiel ihn automatisch und schreibt es ins Terminal
(`Controller: Xbox Series X Controller`).
### Wenn der Controller erkannt wird, aber keine Taste reagiert
Diagnose starten:
```bash
.venv/bin/python tools/controller_check.py
```
Dann A, X, Y und das D-Pad drücken. Das Fenster zeigt jede Taste, die ankommt:
* **Es erscheinen Zeilen** wie `Taste 0 gedrückt` und `→ Spiel-Aktion: PRIMARY`
→ die Eingabe kommt an; dann bitte die Ausgabe melden, es liegt dann an der
Tastenbelegung.
* **Es erscheint gar nichts** → macOS reicht die Controller-Eingabe nicht an
das Terminal weiter. Prüfen unter *Systemeinstellungen → Datenschutz &
Sicherheit → Eingabeüberwachung* (bei älteren macOS-Versionen
*Bedienungshilfen*), ob **Terminal** (bzw. iTerm / VS Code) erlaubt ist –
danach das Terminal neu starten.
* Bei Bluetooth zusätzlich: Controller entkoppeln, neu verbinden, oder einfach
das USB-Kabel verwenden.
---
+2 -1
View File
@@ -92,7 +92,8 @@ loader_game/
menu.py Stufenauswahl
intro.py Anleitungsbild (Tutorial) + Startsequenz (Ampel, Reissverschluss)
game.py Zustandsmaschine einer Runde
tests/test_logic.py 56 Tests (ohne Fenster)
tests/test_logic.py 62 Tests (ohne Fenster)
tools/controller_check.py Diagnose: zeigt jede Controller-Eingabe
docs/ANALYSE.md Analyse Altstand + Fehlerliste
old_version/ eingefrorener Altstand (nicht versioniert)
```
+23
View File
@@ -143,3 +143,26 @@ argument 'slots'` ab.
Support-Fällen.
* Verifiziert mit echtem Python 3.9.25 + pygame 2.6.1: 57 Tests grün,
Selbsttest läuft durch.
## 11. Controller wurde erkannt, reagierte aber nicht (v1.6)
Symptom auf dem MacBook: Im Start-Log stand `Controller: Xbox Series X Controller`,
aber keine Taste löste im Spiel etwas aus – mit Kabel und via Bluetooth.
Ursache: `connected_controllers()` öffnete den Joystick nur, um den Namen
auszulesen, und gab das Python-Objekt danach wieder frei. Damit schloss pygame
das Gerät, und SDL liefert für geschlossene Geräte **keine** Joystick-Events
mehr – die Tastatur funktionierte weiter, der Controller war stumm.
Behoben in `input_map.py`:
* Die Joystick-Objekte werden in `_OPEN_CONTROLLERS` offen gehalten
(`open_controllers()`), aufgerufen beim Start und bei
`JOYDEVICEADDED` / `JOYDEVICEREMOVED` (Hotplug).
* `open_controllers()` initialisiert das Joystick-Subsystem bei Bedarf selbst
und schluckt fehlende Geräte, statt abzubrechen.
* Neues Diagnose-Tool `tools/controller_check.py`: zeigt Name, Tasten-/
Achsen-/Hat-Anzahl, GUID und jedes eingehende Event samt abgeleiteter
Spiel-Aktion.
* Tests für die Tastenbelegung (Tasten 0–3, D-Pad als Hat, Stick mit
Deadzone), 62 Tests insgesamt.
+48 -14
View File
@@ -56,6 +56,53 @@ HAT_ACTIONS: dict[tuple[int, int], Action] = {
#: Analogstick: ab diesem Ausschlag gilt eine Richtung als gewollt
AXIS_DEADZONE = 0.6
#: Offene Joystick-Objekte. Sie müssen am Leben bleiben: gibt Python das
#: Objekt frei, schliesst pygame das Gerät – und SDL liefert danach **keine**
#: Joystick-Events mehr. Genau das war der Grund, weshalb ein erkannter
#: Controller im Spiel nichts bewirkte.
_OPEN_CONTROLLERS: list[pygame.joystick.Joystick] = []
def open_controllers() -> list[str]:
"""Öffnet alle angeschlossenen Controller und hält sie offen.
Nach ``pygame.init()`` aufrufen, und erneut, wenn ein Controller an- oder
abgesteckt wird (``JOYDEVICEADDED`` / ``JOYDEVICEREMOVED``).
"""
_OPEN_CONTROLLERS.clear()
names: list[str] = []
if not pygame.joystick.get_init():
try:
pygame.joystick.init()
except pygame.error:
return names
try:
count = pygame.joystick.get_count()
except pygame.error:
return names
for index in range(count):
try:
stick = pygame.joystick.Joystick(index)
stick.init()
except pygame.error:
continue
_OPEN_CONTROLLERS.append(stick)
names.append(stick.get_name())
return names
def opened_controllers() -> list[pygame.joystick.Joystick]:
"""Die aktuell offenen Controller (für Diagnose-Tools)."""
return list(_OPEN_CONTROLLERS)
def controller_names() -> list[str]:
return [stick.get_name() for stick in _OPEN_CONTROLLERS]
def is_device_event(event: pygame.event.Event) -> bool:
return event.type in (pygame.JOYDEVICEADDED, pygame.JOYDEVICEREMOVED)
def actions_from_event(event: pygame.event.Event) -> list[Action]:
"""Übersetzt ein pygame-Event in null bis eine Aktion."""
@@ -79,21 +126,8 @@ def actions_from_event(event: pygame.event.Event) -> list[Action]:
return []
def connected_controllers() -> list[str]:
"""Namen aller angeschlossenen Controller (leer, wenn keiner da ist)."""
names = []
for index in range(pygame.joystick.get_count()):
try:
stick = pygame.joystick.Joystick(index)
stick.init()
names.append(stick.get_name())
except pygame.error:
continue
return names
def describe_controller_setup() -> str:
names = connected_controllers()
names = controller_names()
if not names:
return "Controller: keiner erkannt (Tastatur: Space=Snap, ←=Drehen, ↑=OnTop, Esc=Ende)"
return "Controller: " + ", ".join(names)
+15 -1
View File
@@ -28,7 +28,14 @@ from loader_game import config
from loader_game.assets import Assets
from loader_game.difficulty import DEFAULT_DIFFICULTY, Difficulty
from loader_game.game import Game, HandleResult, State
from loader_game.input_map import Action, actions_from_event, controller_hint_lines
from loader_game.input_map import (
Action,
actions_from_event,
controller_hint_lines,
describe_controller_setup,
is_device_event,
open_controllers,
)
from loader_game.intro import StartSequence, Tutorial
from loader_game.menu import DifficultyMenu
from loader_game.sequences import load_sequences
@@ -98,6 +105,9 @@ def main(argv: list[str] | None = None) -> int:
pygame.display.set_caption(config.WINDOW_TITLE)
screen = create_screen(fullscreen=args.fullscreen)
# Controller öffnen und offen halten – sonst liefert SDL keine Events.
open_controllers()
assets = Assets()
assets.load()
@@ -216,6 +226,10 @@ def main(argv: list[str] | None = None) -> int:
if event.type == pygame.KEYDOWN and event.key == pygame.K_F11:
pygame.display.toggle_fullscreen()
continue
if is_device_event(event):
print(describe_controller_setup())
open_controllers()
continue
for action in actions_from_event(event):
if not apply_action(action):
running = False
+94
View File
@@ -14,10 +14,13 @@ import unittest
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
import pygame # noqa: E402 (muss nach den SDL-Variablen kommen)
from loader_game import config
from loader_game.case import Case
from loader_game.difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, by_key
from loader_game.game import Game, HandleResult, State
from loader_game import input_map
from loader_game.input_map import Action
from loader_game.intro import StartSequence, Tutorial
from loader_game.menu import DifficultyMenu
@@ -493,6 +496,97 @@ class SequenceChoiceTests(unittest.TestCase):
self.assertEqual(game.sequence_index, 0)
class ControllerMappingTests(unittest.TestCase):
"""Controller-Events → Aktionen (ohne echtes Gerät)."""
@staticmethod
def event(**kwargs):
import types
return types.SimpleNamespace(**kwargs)
def test_keyboard(self) -> None:
self.assertEqual(
input_map.actions_from_event(self.event(type=pygame.KEYDOWN, key=pygame.K_SPACE)),
[Action.PRIMARY],
)
self.assertEqual(
input_map.actions_from_event(self.event(type=pygame.KEYDOWN, key=pygame.K_ESCAPE)),
[Action.BACK],
)
self.assertEqual(
input_map.actions_from_event(self.event(type=pygame.KEYDOWN, key=pygame.K_a)), []
)
def test_xbox_buttons(self) -> None:
for button, expected in (
(0, Action.PRIMARY),
(1, Action.BACK),
(2, Action.LEFT),
(3, Action.UP),
):
self.assertEqual(
input_map.actions_from_event(
self.event(type=pygame.JOYBUTTONDOWN, button=button, joy=0)
),
[expected],
)
self.assertEqual(
input_map.actions_from_event(
self.event(type=pygame.JOYBUTTONDOWN, button=7, joy=0)
),
[],
)
def test_dpad_as_hat(self) -> None:
for value, expected in (
((-1, 0), Action.LEFT),
((1, 0), Action.RIGHT),
((0, -1), Action.UP),
((0, 1), Action.DOWN),
):
self.assertEqual(
input_map.actions_from_event(
self.event(type=pygame.JOYHATMOTION, hat=0, value=value, joy=0)
),
[expected],
)
self.assertEqual(
input_map.actions_from_event(
self.event(type=pygame.JOYHATMOTION, hat=0, value=(0, 0), joy=0)
),
[],
)
def test_left_stick_with_deadzone(self) -> None:
self.assertEqual(
input_map.actions_from_event(
self.event(type=pygame.JOYAXISMOTION, axis=0, value=-0.9, joy=0)
),
[Action.LEFT],
)
self.assertEqual(
input_map.actions_from_event(
self.event(type=pygame.JOYAXISMOTION, axis=1, value=0.9, joy=0)
),
[Action.DOWN],
)
self.assertEqual(
input_map.actions_from_event(
self.event(type=pygame.JOYAXISMOTION, axis=0, value=0.2, joy=0)
),
[],
)
def test_open_controllers_without_devices_does_not_crash(self) -> None:
# Im Testrechner ist kein Controller angeschlossen – das muss ruhig
# eine leere Liste ergeben (und darf keine Ausnahme werfen).
names = input_map.open_controllers()
self.assertIsInstance(names, list)
self.assertEqual(names, input_map.controller_names())
class RenderTests(unittest.TestCase):
"""Rendert je ein Bild von Anleitung, Startsequenz, Menü und Spiel (ohne Fenster).
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Controller-Diagnose: zeigt, was pygame vom Gamepad sieht.
Aufruf im Projektordner::
.venv/bin/python tools/controller_check.py
Drücke ein paar Tasten und bewege das D-Pad. Was hier ankommt, kommt auch im
Spiel an. Beenden mit Esc oder durch Schliessen des Fensters.
"""
from __future__ import annotations
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import pygame
from loader_game import input_map
WIDTH, HEIGHT = 1000, 520
MAX_LINES = 12
def describe(stick: pygame.joystick.Joystick) -> str:
return (
f"{stick.get_name()} · Tasten {stick.get_numbuttons()}"
f" · Achsen {stick.get_numaxes()}"
f" · Hats {stick.get_numhats()}"
)
def main() -> int:
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Controller-Test – Esc beendet")
font = pygame.font.SysFont(None, 28)
small = pygame.font.SysFont(None, 22)
input_map.open_controllers()
names = input_map.controller_names()
print("Gefundene Controller:", names or "keiner")
for stick in input_map.opened_controllers():
print(" -", describe(stick))
print(" GUID:", stick.get_guid())
lines: list[str] = []
events_seen = 0
quiet = 0.0
clock = pygame.time.Clock()
running = True
while running:
quiet += clock.tick(60) / 1000.0
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
break
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
running = False
break
if input_map.is_device_event(event):
input_map.open_controllers()
text = "Controller-Wechsel: " + (", ".join(input_map.controller_names()) or "keiner mehr")
lines.append(text)
print(text)
quiet = 0.0
continue
if event.type == pygame.JOYBUTTONDOWN:
events_seen += 1
quiet = 0.0
text = f"Taste {event.button} gedrückt (joy={event.joy})"
lines.append(text)
print(text)
elif event.type == pygame.JOYHATMOTION:
events_seen += 1
quiet = 0.0
text = f"D-Pad/Hat {event.hat} = {event.value} (joy={event.joy})"
lines.append(text)
print(text)
elif event.type == pygame.JOYAXISMOTION and abs(event.value) > 0.5:
events_seen += 1
quiet = 0.0
text = f"Achse {event.axis} = {event.value:+.2f} (joy={event.joy})"
lines.append(text)
print(text)
for action in input_map.actions_from_event(event):
text = f" → Spiel-Aktion: {action.name}"
lines.append(text)
print(text)
lines = lines[-MAX_LINES:]
screen.fill((20, 20, 24))
screen.blit(font.render("Controller-Test", True, (255, 255, 255)), (24, 20))
current = input_map.controller_names()
if current:
for index, text in enumerate(current):
screen.blit(small.render("✓ " + describe_short(text, input_map), True, (120, 230, 120)), (24, 64 + index * 26))
else:
screen.blit(small.render("Kein Controller gefunden.", True, (240, 120, 120)), (24, 64))
y = 140
for text in lines:
screen.blit(small.render(text, True, (220, 220, 220)), (24, y))
y += 26
if quiet > 5.0 and events_seen == 0:
hint = "Noch keine Controller-Eingabe empfangen – Taste drücken oder Kabel prüfen."
screen.blit(small.render(hint, True, (240, 200, 80)), (24, HEIGHT - 40))
screen.blit(
small.render("Esc beendet den Test.", True, (150, 150, 150)),
(24, HEIGHT - 70),
)
pygame.display.flip()
pygame.quit()
return 0
def describe_short(name: str, module) -> str:
for stick in module.opened_controllers():
if stick.get_name() == name:
return describe(stick)
return name
if __name__ == "__main__":
sys.exit(main())