diff --git a/MAC-START.md b/MAC-START.md index b9f801f..df7efcf 100644 --- a/MAC-START.md +++ b/MAC-START.md @@ -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. --- diff --git a/README.md b/README.md index f89d299..d94e56e 100644 --- a/README.md +++ b/README.md @@ -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) ``` diff --git a/docs/ANALYSE.md b/docs/ANALYSE.md index fb5823a..bcb3c3f 100644 --- a/docs/ANALYSE.md +++ b/docs/ANALYSE.md @@ -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. diff --git a/loader_game/input_map.py b/loader_game/input_map.py index 3da2a35..cbe4173 100644 --- a/loader_game/input_map.py +++ b/loader_game/input_map.py @@ -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) diff --git a/main.py b/main.py index b023ce6..e5e36f2 100755 --- a/main.py +++ b/main.py @@ -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 diff --git a/tests/test_logic.py b/tests/test_logic.py index dece00b..741c52d 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -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). diff --git a/tools/controller_check.py b/tools/controller_check.py new file mode 100644 index 0000000..05fa226 --- /dev/null +++ b/tools/controller_check.py @@ -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())