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:
1 parent
a088c23a09
commit
5237b0a39c
7 files changed
+343
-17
No files matched your search
+24
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user