commit 82d52be8a5be7e07b2fa5b913a0b588170108e82 Author: ScriptSorcerer Date: Thu Sep 24 15:45:31 2026 +0200 proejct commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d238431 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f560d6 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# Physik-Sandbox + +Interaktive 2D-Physiksimulation in Python. Über eine Werkzeugleiste erzeugst du Kreise und +Rechtecke unterschiedlicher Größe, greifst sie mit der Maus, ziehst sie herum und wirfst +sie gegeneinander. Die Körper fallen, prallen ab, reiben, rollen, stapeln sich und kommen +stabil zur Ruhe. + +Die **gesamte Physik ist selbst geschrieben** – Vektormathematik, Integration, +Kollisionserkennung und impulsbasierte Kollisionsauflösung verwenden ausschließlich das +Standardmodul `math`. [pygame-ce](https://pyga.me/) dient nur für Fenster, Zeichnen, +Eingaben und Zeitmessung. + +![Demo-Szene: Ein Rechteck wird gerade geworfen, daneben Kreispyramide und Rechteckturm](docs/images/screenshot.png) + +## Funktionen + +- Kreise und achsenparallele Rechtecke; Größe, Seitenverhältnis, Elastizität, Dichte und + Farbe in der Sidebar einstellbar – die Masse ergibt sich aus Fläche × Dichte. +- Objekte per Klick in die Welt oder per Ziehen aus der Vorschau-Kachel erzeugen. +- Drag & Drop mit Kollisionen: Ein gezogenes Objekt schiebt andere weg und wird von Wänden + gestoppt. Beim Loslassen erhält es die geglättete Mausgeschwindigkeit (Werfen). +- Schwerkraft, Kreis–Kreis-, Kreis–Rechteck-, Rechteck–Rechteck- und Wandkollisionen, + Restitution, Coulomb-Reibung, Rollen und Rollwiderstand. +- Fester Zeitschritt (120 Hz), semi-impliziter Euler, Warm-Starting, Positionskorrektur, + Schlafmodus für ruhende Körper, Geschwindigkeitsgrenze gegen Tunneln. +- Pause, Schwerkraft an/aus, Alles löschen, Debug-Ansicht mit Kontaktpunkten. + +## Voraussetzungen + +- Python **3.10 oder neuer** (entwickelt und getestet mit Python 3.14) +- Windows, macOS oder Linux + +> **Warum pygame-ce?** Das Original-`pygame` (2.6.1) bietet keine fertigen Pakete für +> Python 3.14. `pygame-ce` ist der API-kompatible Community-Fork; im Code heißt es weiterhin +> `import pygame`. + +## Einrichtung + +Im Projektordner eine virtuelle Umgebung anlegen und die Abhängigkeiten installieren: + +```bash +python -m venv .venv +``` + +Aktivieren – unter Windows (PowerShell): + +```powershell +.venv\Scripts\Activate.ps1 +``` + +Meldet PowerShell "Die Ausführung von Skripts ist auf diesem System deaktiviert", erlaube +lokale Skripte einmalig für deinen Benutzer (danach die Aktivierung wiederholen): + +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + +Alternativ ohne Aktivierung: Ersetze in allen folgenden Befehlen + +| Befehl | durch | +|---|---| +| `python` | `.venv\Scripts\python.exe` | +| `pip` | `.venv\Scripts\python.exe -m pip` | +| `pytest` | `.venv\Scripts\python.exe -m pytest` | +| `ruff` | `.venv\Scripts\ruff.exe` | +| `physics-sandbox` | `.venv\Scripts\physics-sandbox.exe` | + +Unter macOS/Linux: + +```bash +source .venv/bin/activate +``` + +Dann installieren: + +```bash +pip install -r requirements.txt +``` + +`requirements.txt` installiert `pygame-ce` und das Projekt selbst im Editable-Modus +(`-e .`). Den Befehl deshalb im Projektordner ausführen. + +## Starten + +```bash +python -m sandbox +``` + +Weitere Möglichkeiten: + +| Befehl | Wirkung | +|---|---| +| `python -m sandbox --demo` | startet mit einer Beispielszene (Turm, Kreispyramide, Gummibälle) | +| `physics-sandbox` | gleichwertiger Befehl, den die Installation anlegt | +| `python -m sandbox --demo --screenshot bild.png --seconds 3` | läuft 3 s, speichert ein Bildschirmfoto und beendet sich | + +## Bedienung (Kurzfassung) + +| Eingabe | Aktion | +|---|---| +| Linksklick ins Leere | Objekt erzeugen (gedrückt halten und ziehen = sofort werfen) | +| Linksklick auf Objekt + Ziehen | greifen und verschieben; Loslassen in Bewegung wirft es | +| Ziehen aus der Vorschau-Kachel | neues Objekt aus der Sidebar in die Welt ziehen | +| Rechtsklick / `Entf` | Objekt unter der Maus löschen | +| Mausrad | Größe ändern (über einem Regler: diesen Regler verstellen) | +| `Leertaste` · `G` · `C` | Pause/Weiter · Schwerkraft an/aus · Alles löschen | +| `1` · `2` | Kreis · Rechteck | +| `F1` · `Esc` | Debug-Ansicht · Beenden | + +Alle Details: [docs/CONTROLS.md](docs/CONTROLS.md). + +## Entwicklung + +Entwicklungswerkzeuge (pytest, pytest-cov, ruff) installieren: + +```bash +pip install -r requirements-dev.txt +``` + +Tests ausführen (inklusive Headless-UI-Tests – es öffnet sich kein Fenster): + +```bash +pytest +``` + +Mit Testabdeckung: + +```bash +pytest --cov=sandbox --cov-report=term-missing +``` + +Code prüfen und formatieren: + +```bash +ruff check src tests +``` + +```bash +ruff format src tests +``` + +Details zu den Tests: [docs/TESTING.md](docs/TESTING.md). + +## Projektstruktur + +```text +├── src/sandbox/ +│ ├── main.py Einstiegspunkt, App und Game-Loop +│ ├── config.py alle Konstanten (Physik, Interaktion, Farben, Layout) +│ ├── physics/ eigene Physik-Engine – nur `math`, kein pygame +│ │ ├── vector.py Vector2 +│ │ ├── shapes.py Circle, Box (AABB), HalfPlane (Wände) +│ │ ├── body.py Body, Material +│ │ ├── collision.py Kollisionserkennung (Narrowphase) +│ │ ├── broadphase.py Sweep-and-Prune +│ │ ├── solver.py Impulsauflösung, Reibung, Rollwiderstand, Positionskorrektur +│ │ ├── sleep.py Schlafmodus (Kontakt-Inseln) +│ │ ├── timestep.py fester Zeitschritt +│ │ └── world.py Welt und Simulationsschritt +│ ├── interaction/ Werkzeuge ohne pygame: Erzeugen, Ziehen, Werfen +│ ├── ui/ Sidebar und selbst gezeichnete Widgets +│ ├── input_handler.py Maus und Tastatur → Aktionen +│ ├── renderer.py Zeichnen der Welt +│ ├── viewport.py Bildschirm ↔ Welt +│ └── demo.py Beispielszene +├── tests/ pytest-Suite (Physik, Interaktion, Headless-UI, Smoke-Test) +├── docs/ Dokumentation +├── requirements.txt Laufzeit-Abhängigkeiten +├── requirements-dev.txt Test- und Linting-Werkzeuge +└── pyproject.toml Paket-, ruff- und pytest-Konfiguration +``` + +## Dokumentation + +- [docs/PHYSIK_EINSTIEG.md](docs/PHYSIK_EINSTIEG.md) – die Physik Schritt für Schritt für Einsteiger +- [docs/PHYSICS.md](docs/PHYSICS.md) – die verwendete Physik und ihre Formeln +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) – Module, Datenfluss im Game-Loop, Diagramme +- [docs/TESTING.md](docs/TESTING.md) – Tests ausführen, was getestet wird, Headless-Modus +- [docs/CONTROLS.md](docs/CONTROLS.md) – alle Maus- und Tastaturbefehle + +## Screenshots + +Demo-Szene mit gerade geworfenem Rechteck (gelb umrandet, der Pfeil zeigt die +Wurfgeschwindigkeit): + +![Demo-Szene](docs/images/screenshot.png) + +Debug-Ansicht (`F1`): Kontaktpunkte (gelb), Kontaktnormalen (rot) und Kennzahlen: + +![Debug-Ansicht](docs/images/debug.png) + +Ein aktuelles Bild der Demo-Szene erzeugst du jederzeit mit +`python -m sandbox --demo --screenshot docs/images/demo.png`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..02f10df --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,159 @@ +# Architektur + +## Schichten + +Das Projekt besteht aus drei Schichten. Abhängigkeiten zeigen nur nach unten: + +```mermaid +flowchart TB + subgraph APP["Anwendung (pygame-ce)"] + main["main.py
App · Game-Loop"] + input["input_handler.py
Maus/Tastatur → Aktionen"] + renderer["renderer.py
Welt zeichnen"] + ui["ui/
Sidebar · Widgets · Stil"] + end + subgraph TOOLS["Interaktion (ohne pygame)"] + drag["interaction/drag.py
DragController"] + tracker["interaction/velocity_tracker.py
VelocityTracker"] + tools["interaction/tools.py
ToolSettings · BodyFactory"] + demo["demo.py
Beispielszene"] + viewport["viewport.py
Bildschirm ↔ Welt"] + end + subgraph PHYS["Physik (nur math)"] + world["world.py
World"] + solver["solver.py
ContactSolver"] + collision["collision.py
Narrowphase"] + broad["broadphase.py
Sweep-and-Prune"] + sleep["sleep.py
Schlafmodus"] + body["body.py · shapes.py
Body · Material · Formen"] + vector["vector.py
Vector2"] + timestep["timestep.py
FixedTimestep"] + end + config["config.py
alle Konstanten"] + + APP --> TOOLS + APP --> PHYS + TOOLS --> PHYS + APP --> config + TOOLS --> config + PHYS --> config +``` + +| Schicht | Pakete/Module | Darf importieren | +|---|---|---| +| **Physik** | `sandbox.physics` | nur `math`, `typing`, `dataclasses`, `sandbox.config` | +| **Interaktion** | `sandbox.interaction`, `sandbox.demo`, `sandbox.viewport` | Physik, Standardbibliothek | +| **Anwendung** | `sandbox.main`, `sandbox.input_handler`, `sandbox.renderer`, `sandbox.ui` | alles, inklusive pygame | + +**Warum diese Trennung?** Die Physik soll ohne Grafik laufen und isoliert testbar sein. Das +ist eine Anforderung, und `tests/test_architecture.py` prüft sie doppelt: statisch über den +Syntaxbaum und zur Laufzeit in einem frischen Interpreter, in dem nach +`import sandbox.physics` kein pygame geladen sein darf. Die Interaktionslogik – etwa die +Glättung der Wurfgeschwindigkeit – arbeitet mit Weltkoordinaten und Zeitstempeln statt mit +pygame-Events. So lässt auch sie sich ohne Fenster testen. + +`config.py` enthält ausschließlich Konstanten in unveränderlichen Dataclasses. Im Code gibt +es keine "Magic Numbers". Tests variieren einzelne Werte mit `dataclasses.replace`. + +## Module + +| Modul | Verantwortung | +|---|---| +| `physics/vector.py` | `Vector2`: unveränderlicher 2D-Vektor (Addition, Skalierung, Skalar- und Kreuzprodukt, Länge, Normalisierung …) | +| `physics/shapes.py` | `Circle`, `Box` (AABB), `HalfPlane` (Wand): Fläche, Trägheitsmoment, Punkttest | +| `physics/body.py` | `Material`, `Body`: Zustand, Masse = Fläche × Dichte, inverse Massen, Schlafzustand | +| `physics/collision.py` | `Contact` und die Tests Kreis–Kreis, Kreis–Rechteck, Rechteck–Rechteck, Körper–Wand | +| `physics/broadphase.py` | Sweep-and-Prune: Kandidatenpaare in O(n·log n + k) | +| `physics/solver.py` | `ContactSolver`: Sequential Impulses (Normale, Reibung, Rollwiderstand), Warm-Starting, Positionskorrektur | +| `physics/sleep.py` | Kontakt-Inseln (Union-Find), Einschlafen und Aufwecken | +| `physics/timestep.py` | `FixedTimestep`: Frame-Zeit → Anzahl fester Schritte, Interpolationsgewicht | +| `physics/world.py` | `World`: Körper und Wände verwalten, `step(dt)` orchestrieren | +| `interaction/tools.py` | `ToolSettings` (Sidebar-Auswahl), `BodyFactory` (Körper erzeugen), `BodyStyle` (Farbe) | +| `interaction/drag.py` | `DragController`: Greifen, per Geschwindigkeit ziehen, Werfen | +| `interaction/velocity_tracker.py` | geglättete Mausgeschwindigkeit (Zeitfenster + exponentielle Glättung) | +| `viewport.py` | Verschiebung zwischen Bildschirm- und Weltkoordinaten | +| `input_handler.py` | Events verteilen: Sidebar zuerst, sonst Werkzeug-Aktion in der Welt; Tastenkürzel | +| `ui/widgets.py` | selbst gezeichnete Widgets: `Label`, `Button`, `SegmentedControl`, `Slider`, `ColorPicker`, `SpawnTile` | +| `ui/sidebar.py` | Aufbau und Layout der Werkzeugleiste | +| `ui/style.py`, `ui/drawing.py` | Schriften, Text-Cache, Zeichenhelfer für Kreise und Rechtecke | +| `renderer.py` | Welt zeichnen: Interpolation, Hervorhebung, Wurfpfeil, Vorschau, Debug-Ansicht | +| `main.py` | `App`: alles verdrahten, Hauptschleife, Kommandozeile | +| `demo.py` | Beispielszene für `--demo` | + +## Datenfluss im Game-Loop + +Die Hauptschleife (`App.run`) ruft pro Bild `App.frame(frame_time)` auf: + +```mermaid +sequenceDiagram + autonumber + participant Loop as App.run + participant App as App.frame + participant Input as InputHandler + participant Sidebar + participant Drag as DragController + participant Step as FixedTimestep + participant World + participant Draw as Renderer + Sidebar + + Loop->>App: frame_time (Clock.tick, max. 60 FPS) + loop alle pygame-Events + App->>Input: handle_event(event, zeit) + Input->>Sidebar: Event anbieten + alt Sidebar verbraucht es + Sidebar-->>Input: True (Regler, Button, Kachel …) + else Klick/Bewegung in der Welt + Input->>Drag: grab / move / release + Input->>World: Körper erzeugen / löschen + end + end + App->>Input: update(zeit) – Mausposition an Tracker, Hover bestimmen + App->>Step: advance(frame_time) + Step-->>App: n feste Schritte (meist 2 × 1/120 s) + loop n-mal + App->>Drag: apply(dt) – Geschwindigkeit Richtung Maus + App->>World: step(dt) + end + App->>Draw: Welt (interpoliert mit alpha), Sidebar, Vorschau + App->>Loop: display.flip() +``` + +Wichtige Punkte: + +- **Entkopplung:** Die Bildrate (bis 60 FPS) und die Physik (fest 120 Hz) sind unabhängig. + Der Akkumulator in `FixedTimestep` übersetzt zwischen beiden. Das Interpolationsgewicht + `alpha` glättet die Darstellung. +- **Ziehen im Physiktakt:** `DragController.apply` läuft vor *jedem* Physikschritt, nicht + nur einmal pro Bild. Der gezogene Körper wird wie jeder andere vom Solver behandelt. +- **Mausgeschwindigkeit pro Bild:** `InputHandler.update` meldet die Mausposition in jedem + Frame an den Tracker, auch ohne Maus-Event. Nur so merkt er, dass die Maus stillsteht. +- **Pause:** Keine Physikschritte. Ein gezogener Körper wird direkt versetzt + (`DragController.place`). + +## Ablauf eines Physikschritts + +Details und Formeln in [PHYSICS.md](PHYSICS.md#4-ablauf-eines-simulationsschritts): + +```mermaid +flowchart LR + Z[Zustand merken] --> A[v += g·dt] --> B[Broadphase] --> C[Narrowphase] + C --> D[Schlaf-Inseln
wecken] --> E[Wandkontakte] --> F[Solver:
Warm-Start +
8 Iterationen] + F --> G[v begrenzen] --> H[x += v·dt] --> I[Positions-
korrektur] + I --> K[Notbremse] --> J[Schlafmodus] +``` + +## Entwurfsentscheidungen + +| Entscheidung | Begründung | +|---|---| +| `Vector2` ist unveränderlich (Zuweisung löst `AttributeError` aus) | Keine Aliasing-Fehler: Ein Körper kann seine Position herausgeben, ohne dass jemand sie über die geteilte Referenz verändert. | +| Solver-Schleifen rechnen mit Floats statt mit `Vector2` | Sie laufen tausendfach pro Schritt. Objekt-Erzeugung ist in Python teuer; die Formeln sind komponentenweise identisch. | +| Kontakte kanonisch geordnet (Kreis vor Rechteck vor Wand, sonst nach `id`) | Ein Körperpaar hat in jedem Schritt dieselbe Orientierung – Voraussetzung für das Warm-Starting. | +| Wände sind statische `Body`-Objekte mit `HalfPlane`-Form | Der Solver behandelt Wandkontakte ohne Sonderfall (inverse Masse 0). Halbebenen sind tunnelsicher. | +| Rechtecke drehen nie (Trägheitsmoment unendlich) | Die Aufgabe verlangt achsenparallele Rechtecke; jeder Kontakt braucht dann nur einen Punkt. | +| Ziehen über Geschwindigkeit statt Teleport | Das gezogene Objekt kollidiert weiter und kann nicht durch Wände geschoben werden. | +| Positionskorrektur per Projektion statt Baumgarte-Geschwindigkeit | Überlappende Objekte werden getrennt, ohne davongeschleudert zu werden. | +| Schlafmodus über Kontakt-Inseln, die gemeinsam einschlafen und gemeinsam aufwachen | Verhindert das gegenseitige Aufwecken einzelner Körper und schlafende Körper, die nach dem Wegziehen ihrer Stütze in der Luft hängen. | +| Abprallen aus der Geschwindigkeit vom Schrittbeginn | Sonst ginge die im selben Schritt addierte Schwerkraft in den Rückprall ein – ein elastischer Ball gewönne Höhe. | +| Widgets fragen Zustände über Callbacks ab | Die Anzeige stimmt immer, auch wenn ein Wert per Tastenkürzel geändert wurde. | +| Keine GUI-Bibliothek | pygame-ce genügt für die wenigen Widgets; weniger Abhängigkeiten. | diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md new file mode 100644 index 0000000..7060c10 --- /dev/null +++ b/docs/CONTROLS.md @@ -0,0 +1,55 @@ +# Bedienung + +## Maus in der Welt + +| Eingabe | Aktion | +|---|---| +| **Linksklick ins Leere** | Erzeugt ein Objekt mit den Einstellungen der Sidebar an der Mausposition. Hält man die Taste gedrückt, hängt das neue Objekt sofort an der Maus und kann direkt geworfen werden. | +| **Linksklick auf ein Objekt, gedrückt halten** | Greift das Objekt (gelbe Umrandung). Es folgt der Maus, kollidiert dabei aber weiter: Leichtere Objekte werden weggeschoben, Wände und schwere Stapel bremsen es. | +| **Loslassen während der Bewegung** | Wirft das Objekt mit der (geglätteten) Mausgeschwindigkeit der letzten Frames. Der gelbe Pfeil zeigt beim Ziehen Richtung und Stärke des Wurfs. | +| **Loslassen nach kurzem Stillhalten** | Das Objekt fällt einfach herunter, ohne Schwung. | +| **Rechtsklick auf ein Objekt** | Löscht das Objekt. | +| **Mausrad** | Ändert die Größe neuer Objekte. | +| **Maus über einem Objekt** | Weiße Umrandung und Hand-Cursor: Das Objekt kann gegriffen werden. | +| **Maus über leerer Fläche** | Halbtransparente Vorschau des Objekts, das ein Klick erzeugen würde. | + +## Sidebar + +| Element | Wirkung | +|---|---| +| **Kreis / Rechteck** | Form neuer Objekte. | +| **Größe** | Radius bzw. halbe Kantenlänge (12–80 px). | +| **Seitenverhältnis** | Breite : Höhe von Rechtecken (0,5–2). Die Fläche und damit die Masse bleibt dabei gleich – außer bei sehr kleinen Rechtecken, deren kurze Seite nicht unter die Mindestgröße (24 px) fallen darf. Bei Kreisen ausgegraut. | +| **Elastizität** | Stoßzahl 0 (kein Abprallen) bis 1 (vollkommen elastisch). | +| **Dichte** | Masse pro Fläche (0,2–5). Schwere Objekte schieben leichte leichter weg. | +| **Farbe** | Feste Farbe oder (erstes Feld) Zufallsfarbe. | +| **Vorschau-Kachel** | Zeigt das nächste Objekt in seiner echten Farbe. Mit gedrückter Maustaste in die Welt ziehen: Das Objekt erscheint am Weltrand und hängt an der Maus. Wird über der Sidebar losgelassen, entsteht kein Objekt. | +| **Pause / Weiter** | Hält die Simulation an. Im Pausenmodus lassen sich Objekte erzeugen und exakt platzieren; beim Loslassen werden sie nicht geworfen. | +| **Schwerkraft an / aus** | Schaltet die Schwerkraft um und weckt alle ruhenden Objekte. | +| **Alles löschen** | Entfernt alle Objekte. | + +Alle Regler reagieren auf Klicken, Ziehen und das Mausrad (in Rasterschritten). Beim Ziehen +darf die Maus die Sidebar verlassen. + +## Tastatur + +| Taste | Aktion | +|---|---| +| `Leertaste` | Pause / Weiter | +| `G` | Schwerkraft an / aus | +| `C` | Alles löschen | +| `Entf` | Objekt unter der Maus löschen | +| `1` | Form: Kreis | +| `2` | Form: Rechteck | +| `F1` | Debug-Ansicht: Kontaktpunkte (gelb), Kontaktnormalen (rot), schlafende Objekte (graue Umrandung), Kennzahlen | +| `Esc` | Beenden | + +Die Tastenbelegung steht in `KEY_BINDINGS` in [`src/sandbox/config.py`](../src/sandbox/config.py). + +## Kommandozeile + +| Option | Wirkung | +|---|---| +| `--demo` | startet mit einer Beispielszene | +| `--screenshot DATEI` | läuft `--seconds` Sekunden, speichert ein Bildschirmfoto und beendet sich | +| `--seconds S` | Laufzeit vor dem Bildschirmfoto (Standard: 3) | diff --git a/docs/PHYSICS.md b/docs/PHYSICS.md new file mode 100644 index 0000000..bef9135 --- /dev/null +++ b/docs/PHYSICS.md @@ -0,0 +1,475 @@ +# Physik der Sandbox + +Dieses Dokument erklärt, *was* die Physik-Engine berechnet und *warum* sie es so tut. Alle +Formeln finden sich im Code wieder, meist mit derselben Bezeichnung. Die Zahlenwerte stehen +in [`src/sandbox/config.py`](../src/sandbox/config.py) (`PhysicsConfig`). + +Die Physik-Schicht (`src/sandbox/physics/`) verwendet ausschließlich das Standardmodul +`math` sowie `typing`/`dataclasses` für Typangaben. Ein Architektur-Test stellt sicher, +dass dort nie pygame importiert wird. + +## Inhalt + +1. [Einheiten und Koordinaten](#1-einheiten-und-koordinaten) +2. [Körper: Masse und Trägheit](#2-körper-masse-und-trägheit) +3. [Integration mit festem Zeitschritt](#3-integration-mit-festem-zeitschritt) +4. [Ablauf eines Simulationsschritts](#4-ablauf-eines-simulationsschritts) +5. [Kollisionserkennung](#5-kollisionserkennung) +6. [Impulsbasierte Kollisionsauflösung](#6-impulsbasierte-kollisionsauflösung) +7. [Restitution (Elastizität)](#7-restitution-elastizität) +8. [Reibung und Rollen](#8-reibung-und-rollen) +9. [Rollwiderstand](#9-rollwiderstand) +10. [Positionskorrektur](#10-positionskorrektur) +11. [Ruhe ohne Zittern: Schlafmodus](#11-ruhe-ohne-zittern-schlafmodus) +12. [Tunneln verhindern](#12-tunneln-verhindern) +13. [Ziehen und Werfen](#13-ziehen-und-werfen) +14. [Parameterübersicht](#14-parameterübersicht) + +--- + +## 1. Einheiten und Koordinaten + +| Größe | Einheit | +|---|---| +| Länge | Pixel (px) | +| Zeit | Sekunde (s) | +| Geschwindigkeit | px/s | +| Masse | Fläche × Dichte (px² · Dichte; nur Verhältnisse zählen) | + +Der Ursprung liegt oben links in der Welt, **x zeigt nach rechts, y nach unten** – wie auf +dem Bildschirm. Die Schwerkraft ist deshalb `g = (0, +980) px/s²`; bei einem Maßstab von +100 px pro Meter entspricht das 9,8 m/s². + +Weil y nach unten zeigt, bedeutet ein positiver Drehwinkel eine Drehung **im +Uhrzeigersinn** auf dem Bildschirm. Die Formeln sind davon unabhängig, solange man +konsequent `a × b = a.x·b.y − a.y·b.x` verwendet. + +## 2. Körper: Masse und Trägheit + +Jeder Körper (`Body`) hat eine Form, eine Position (Schwerpunkt), eine Geschwindigkeit und +bei Kreisen zusätzlich Winkel und Winkelgeschwindigkeit. + +**Masse = Fläche × Dichte.** Damit sind größere Körper bei gleicher Dichte schwerer: + +| Form | Fläche | Trägheitsmoment | +|---|---|---| +| Kreis (Radius r) | `π·r²` | `I = ½·m·r²` (homogene Scheibe) | +| Rechteck (halbe Kanten hw, hh) | `4·hw·hh` | unendlich (dreht nie) | + +Herleitung des Scheiben-Trägheitsmoments: Die Scheibe zerfällt in dünne Ringe mit Radius s +und Masse `dm = m/(π·r²) · 2π·s·ds`. Jeder Ring trägt `s²·dm` bei: + +```text +I = (2m / r²) · ∫₀ʳ s³ ds = (2m / r²) · r⁴/4 = ½·m·r² +``` + +Rechtecke sind laut Aufgabenstellung **achsenparallel**. Ein unendliches Trägheitsmoment +(also `1/I = 0`) garantiert, dass kein Drehimpuls sie jemals drehen kann. + +**Inverse Massen.** Gespeichert werden zusätzlich `1/m` und `1/I`. Wände haben `1/m = 0` +(unendliche Masse). So passen bewegliche und unbewegliche Körper ohne Sonderfälle in +dieselben Formeln: Ein Impuls P ändert die Geschwindigkeit um `P/m` – bei einer Wand um 0. + +## 3. Integration mit festem Zeitschritt + +### Semi-impliziter Euler + +Pro Schritt `dt = 1/120 s`: + +```text +v ← v + g·dt (zuerst die Geschwindigkeit …) +x ← x + v·dt (… dann die Position mit der NEUEN Geschwindigkeit) +θ ← θ + ω·dt +``` + +Der explizite Euler (Position mit der *alten* Geschwindigkeit) gewinnt bei Schwingungen und +Umlaufbahnen systematisch Energie und wird instabil. Der semi-implizite (symplektische) +Euler kostet genauso wenig, bleibt aber energetisch stabil. + +Für den freien Fall ergibt sich nach n Schritten exakt `v = n·g·dt` und +`s = g·dt²·n(n+1)/2`. Das weicht nur um den Faktor `(1 + 1/n)` vom analytischen +`s = ½·g·t²` ab; nach 0,5 s sind das 1,7 %. Genau diese Beziehung prüft ein Test. + +### Fester Zeitschritt, entkoppelt von der Bildrate + +Die Stabilität hängt von `dt` ab. Mit der schwankenden Frame-Dauer als `dt` würde sich die +Simulation auf jedem Rechner anders verhalten. Ein Ruckler von 200 ms ließe Körper außerdem +durch andere hindurchspringen. Deshalb sammelt ein Akkumulator (`FixedTimestep`) die real +vergangene Zeit und gibt sie in festen Portionen weiter: + +```text +akkumulator += frame_zeit +while akkumulator ≥ dt: world.step(dt); akkumulator −= dt +alpha = akkumulator / dt (Rest für die Interpolation) +``` + +- **Obergrenze** `max_steps_per_frame`: Schafft der Rechner die Physik nicht in Echtzeit, + wird der Rückstand verworfen, statt ihn endlos mitzuschleppen ("Spiral of Death"). +- **Interpolation:** Gezeichnet wird `lerp(vorige_position, position, alpha)`. Die Physik + läuft mit 120 Hz, der Bildschirm z. B. mit 60 Hz – ohne Interpolation ruckelt die + Bewegung sichtbar. + +## 4. Ablauf eines Simulationsschritts + +`World.step(dt)` führt nacheinander aus: + +```mermaid +flowchart TD + A[Zustand merken
für Interpolation] --> B[Geschwindigkeiten integrieren
v += g·dt] + B --> C[Broadphase
Sweep-and-Prune] + C --> D[Narrowphase
Kontakte Körper–Körper] + D --> E[Berührte Schläfer wecken] + E --> F[Wandkontakte] + F --> G[Impulse lösen
Normale · Reibung · Rollwiderstand] + G --> H[Geschwindigkeit begrenzen] + H --> I[Positionen integrieren
x += v·dt] + I --> J[Positionskorrektur] + J --> K[Notbremse an den Wänden] + K --> L[Schlafmodus aktualisieren] +``` + +Vor jedem Schritt setzt die Anwendung die Geschwindigkeit eines gezogenen Körpers +(siehe [Abschnitt 13](#13-ziehen-und-werfen)). + +## 5. Kollisionserkennung + +Jede Prüfung liefert einen **Kontakt** mit + +- **Normale n** – Einheitsvektor von Körper A zu Körper B, +- **Eindringtiefe** – Überlappung entlang n (≥ 0), +- **Kontaktpunkt** – Angriffspunkt der Kräfte. + +### Broadphase: Sweep-and-Prune + +Alle Paare genau zu prüfen kostet n·(n−1)/2 Tests – bei 150 Körpern über 11 000 pro +Schritt. Deshalb werden die umschließenden Boxen nach ihrer linken Kante sortiert. Für jede +Box müssen nur die Nachfolger geprüft werden, die links von ihrer rechten Kante beginnen. +Der Aufwand sinkt auf etwa O(n·log n + k) für k benachbarte Paare. Paare aus zwei +schlafenden Körpern werden übersprungen. + +### Kreis–Kreis + +```text +d = |p_B − p_A|, Kontakt, wenn d ≤ r_A + r_B +n = (p_B − p_A) / d, Tiefe = r_A + r_B − d +``` + +Der Kontaktpunkt liegt in der Mitte des Überlappungsbereichs. Liegen die Mittelpunkte exakt +aufeinander, ist die Richtung unbestimmt. Dann wird die feste Ersatznormale (0, −1) +verwendet, statt durch 0 zu teilen. + +### Kreis–Rechteck + +Den Kreismittelpunkt (relativ zum Rechteck) komponentenweise auf das Rechteck klemmen. Das +liefert den nächstgelegenen Punkt q des Rechtecks: + +```text +q = (clamp(dx, −hw, hw), clamp(dy, −hh, hh)) +Kontakt, wenn |Mittelpunkt − q| ≤ r +``` + +Sonderfall: Liegt der Kreismittelpunkt *im* Rechteck, ist q der Mittelpunkt selbst und +liefert keine Richtung. Dann wird der Kreis über die nächstgelegene Kante hinausgeschoben; +die Tiefe ist Radius + Abstand zu dieser Kante. + +### Rechteck–Rechteck + +Zwei achsenparallele Rechtecke überlappen genau dann, wenn sich ihre Projektionen auf +**beide** Achsen überlappen (Trennachsensatz): + +```text +overlap_x = hw_A + hw_B − |dx|, overlap_y = hh_A + hh_B − |dy| +``` + +Getrennt wird entlang der Achse mit der kleineren Überlappung – das ist der kürzeste Weg +aus der Durchdringung. Bei Gleichstand gewinnt die y-Achse, weil aufeinanderliegende Körper +der häufigste Fall sind. + +### Wände als Halbebenen + +Boden, Decke und Seiten sind Halbebenen `n·p ≥ offset` (die Normale zeigt in die Welt). +Die Reichweite eines Rechtecks entlang n ist `|n.x|·hw + |n.y|·hh`, die eines Kreises r. +Weil eine Halbebene unendlich dick ist, wird ein Körper **auch dann erkannt, wenn er in +einem Schritt komplett hinter die Wandlinie springt** – er kann nicht hindurchtunneln. + +## 6. Impulsbasierte Kollisionsauflösung + +### Herleitung des Kollisionsimpulses + +Relativgeschwindigkeit des Kontaktpunkts (B relativ zu A), mit den Hebelarmen r vom +Schwerpunkt zum Kontaktpunkt und `ω × r = ω·(−r.y, r.x)`: + +```text +v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), v_n = v_rel · n +``` + +`v_n < 0` bedeutet: Die Körper nähern sich an. Ein Impuls `P = λ·n` wirkt auf B positiv und +auf A negativ (actio = reactio): + +```text +v_A' = v_A − λ·n/m_A ω_A' = ω_A − (r_A × λn)/I_A +v_B' = v_B + λ·n/m_B ω_B' = ω_B + (r_B × λn)/I_B +``` + +Eingesetzt ändert sich die Normalgeschwindigkeit linear in λ: + +```text +v_n' = v_n + λ·K, K = 1/m_A + 1/m_B + (r_A × n)²/I_A + (r_B × n)²/I_B +``` + +Aus Newtons Stoßgesetz `v_n' = −e·v_n` folgt: + +```text +λ = −(1 + e) · v_n / K +``` + +`1/K` ist die **effektive Masse** des Kontakts – die träge Masse, die der Stoß entlang n +"spürt". Für einen einzelnen zentralen Stoß erhält diese Formel den Gesamtimpuls exakt und +liefert die Lehrbuchergebnisse. Beim elastischen Stoß zweier Kreise mit Massenverhältnis +1 : 3 und Geschwindigkeiten 200 bzw. −100 px/s kommen zum Beispiel exakt −250 und 50 px/s +heraus. Ein Test prüft das. + +**Besonderheit dieser Engine:** Bei Kreisen geht die Normale durch den Mittelpunkt +(r ∥ n, also r × n = 0), und Rechtecke drehen sich nie (1/I = 0). Die Drehterme von K +verschwinden daher für die Normale – **Normalimpulse erzeugen kein Drehmoment**. Nur die +Reibung bringt Kreise ins Rollen. + +### Viele Kontakte: Sequential Impulses + +Liegt ein Körper auf mehreren anderen, beeinflussen sich die Kontakte gegenseitig. Statt +ein Gleichungssystem zu lösen, werden die Kontakte reihum mehrfach aufgelöst +(Gauß-Seidel, `velocity_iterations = 8`). Zwei Details machen das Verfahren korrekt und +stabil: + +1. **Akkumulierte Impulse klemmen.** Geklemmt wird der über alle Durchläufe *summierte* + Impuls (`P_n ≥ 0`), nicht der Teilimpuls eines Durchlaufs. Ein späterer Durchlauf darf + einen zu großen früheren Impuls also wieder zurücknehmen. Das Verfahren konvergiert so + gegen die physikalische Lösung, in der Kontakte nur drücken und nie ziehen. +2. **Warm-Starting.** Die akkumulierten Impulse des vorigen Schritts werden zu Beginn erneut + angewendet. Ruhende Kontakte ändern sich kaum, der Startwert ist also schon fast richtig – + Stapel stehen dadurch nach wenigen Durchläufen stabil. Hat sich die Normale stark + gedreht (Skalarprodukt < 0,95), wird der alte Impuls verworfen. + +Reihenfolge je Kontakt und Durchlauf: **Normale → Reibung → Rollwiderstand.** Reibung und +Rollwiderstand sind durch den Normalimpuls begrenzt. Werden sie danach gelöst, gelten ihre +Grenzen nach jedem Durchlauf exakt für den aktuellen Normalimpuls. + +## 7. Restitution (Elastizität) + +Die Stoßzahl `e ∈ [0, 1]` ist das Verhältnis von Trenn- zu Aufprallgeschwindigkeit: +`e = 0` völlig unelastisch, `e = 1` vollkommen elastisch. + +- **Energieverlust:** Beim zentralen Stoß geht `ΔE = ½·μ·(1 − e²)·v_rel²` verloren + (μ = m_A·m_B/(m_A + m_B), die reduzierte Masse). Ein Test prüft genau diesen Wert. +- **Sprunghöhe:** Ein fallender Ball erreicht nach dem Aufprall `e²` seiner Fallhöhe + (Energie ∝ v²). +- **Mischregel:** `e = max(e_A, e_B)` – ein Gummiball springt auch von einem Holzklotz + ab. Wände haben `e = 0`, sodass allein das Material des Körpers zählt. +- **Schwellwert (`restitution_threshold` = 50 px/s):** Unterhalb dieser + Aufprallgeschwindigkeit wird nicht abgeprallt. Sonst würden aufliegende Körper durch + winzige Dauer-Hüpfer zittern. +- **Aufprallgeschwindigkeit vom Schrittbeginn:** `v_n` wird aus den Geschwindigkeiten *vor* + Schwerkraft und Warm-Starting bestimmt (`Body.previous_velocity`). Nähme man die + Geschwindigkeit nach der Schwerkraft, prallte ein Ball mit `e·(v + g·dt)` statt `e·v` ab. + Ein vollkommen elastischer Ball (e = 1) gewönne dann mit jedem Aufprall Höhe – Energie + aus dem Nichts. So springt er dagegen immer wieder genau auf seine Fallhöhe; ein Test + prüft das über mehrere Sprünge. +- **Gehaltene Körper prallen nicht ab:** Die Hand fängt den Stoß ab. Andernfalls würde ein + gegen ein Hindernis gedrückter Körper in jedem Schritt zurückprallen, sofort wieder + herangezogen werden und dabei sichtbar zittern. + +## 8. Reibung und Rollen + +**Coulomb-Reibung:** Entlang der Tangente `t = (−n.y, n.x)` soll die Gleitgeschwindigkeit +verschwinden. Der Reibungsimpuls ist aber begrenzt: + +```text +|P_t| ≤ μ · P_n +``` + +Reicht die Grenze, haften die Körper (Haftreibung). Sonst rutschen sie mit konstanter +Verzögerung `μ·g` (Gleitreibung): Eine Box mit 300 px/s kommt nach `v²/(2μg)` zum Stehen. +Gemischt wird mit dem geometrischen Mittel `μ = √(μ_A·μ_B)` – ein glatter Partner macht +den Kontakt glatt. + +**Rollen:** Der Reibungsimpuls greift am Kontaktpunkt an, nicht im Schwerpunkt. Bei Kreisen +erzeugt er daher ein Drehmoment `r × P_t`, die effektive Masse enthält den Term +`(r × t)²/I`. Eine rutschende Scheibe wird gebremst und zugleich in Drehung versetzt, bis +die Rollbedingung `v = ω·r` gilt. Drehimpulserhaltung um den Kontaktpunkt liefert die +Endgeschwindigkeit – unabhängig vom Reibwert: + +```text +v_end = v₀ / (1 + I/(m·r²)) = v₀ / 1,5 = ⅔ · v₀ +``` + +Auch das prüft ein Test. Damit man die Drehung sieht, zeichnet der Renderer in jeden Kreis +einen Radius-Strich. + +## 9. Rollwiderstand + +Reines Rollen verbraucht keine Reibungsenergie – ein rollender Kreis würde ewig rollen. Ein +Rechteck auf einem kleinen Kreis führe darauf wie auf einer Rolle davon. Real verformen sich +Kreis und Unterlage ein wenig, was ein bremsendes Moment erzeugt: + +```text +M ≤ μ_r · N · r (μ_r = rolling_resistance = 0,1) +``` + +Pro Schritt entspricht das einem Drehimpuls `≤ μ_r · P_n · r`. Umgesetzt ist das wie die +Reibung: als begrenzter Impuls, hier aber auf die relative Winkelgeschwindigkeit +`ω_B − ω_A`, mit der effektiven Masse `1/(1/I_A + 1/I_B)`. Weil die Grenze mit der +Normalkraft wächst, kommen schwer belastete "Rollen" schnell zur Ruhe, während ein frei +rollender Ball weit rollt. + +Für einen rollenden Ball ergibt sich eine konstante Verzögerung: + +```text +a = μ_r·g / (1 + I/(m·r²)) = μ_r·g / 1,5 ≈ 65 px/s² +``` + +Ein Ball mit 200 px/s rollt so etwa 300 px weit. Der Test misst genau diese Verzögerung. + +## 10. Positionskorrektur + +Impulse korrigieren nur Geschwindigkeiten. Durch den diskreten Zeitschritt überlappen +Körper trotzdem ein wenig: Ein fallender Körper "landet" meist schon etwas in der +Unterlage. Ohne Korrektur würden Stapel langsam ineinander einsinken. + +Die Korrektur verschiebt die **Positionen** direkt ("Projektion"), im Verhältnis der +inversen Massen: + +```text +C = factor · max(Tiefe − slop, 0) (höchstens max_position_correction) +p_A −= n · C · (1/m_A) / (1/m_A + 1/m_B) +p_B += n · C · (1/m_B) / (1/m_A + 1/m_B) +``` + +- **Warum Positionen statt Zusatzgeschwindigkeit (Baumgarte)?** Eine Geschwindigkeit, die + Körper auseinandertreibt, bleibt nach der Trennung erhalten und schleudert sie davon – + besonders bei großen Überlappungen, etwa wenn ein Objekt mitten in einen Haufen gesetzt + wird. Die Projektion erzeugt keine Bewegungsenergie. +- **Slop (0,5 px):** Eine kleine Resttiefe bleibt stehen. So wird der Kontakt im nächsten + Schritt sicher wieder erkannt, statt ständig zwischen "Kontakt" und "kein Kontakt" zu + wechseln – auch das wäre sichtbares Zittern. +- **Aktuelle Tiefe ohne erneute Kollisionsprüfung:** Tiefe bei der Erkennung minus die + seither erfolgte Relativverschiebung entlang n. Das ist exakt für Wände und Rechtecke und + eine sehr gute Näherung für Kreise. +- **Notbremse:** Kein Körper darf mehr als `max_wall_penetration` (12 px) in eine Wand + ragen. Der Wert liegt bewusst über der größten Eindringtiefe, die ein frei fliegender + Körper in einem Schritt erreichen kann (`MAX_SPEED · dt ≈ 10,8 px`). Normale Aufpralle + löst deshalb immer der Solver; die Bremse greift nur in Extremfällen, etwa wenn ein + gezogenes Objekt einen Körper gegen die Wand quetscht. Würde sie schon bei normalen + Aufprallen greifen, verschöbe sie Positionen und fügte dabei Lageenergie hinzu. + +## 11. Ruhe ohne Zittern: Schlafmodus + +Ein Solver mit endlich vielen Iterationen hinterlässt immer winzige Restbewegungen. Ein +Stapel "atmet" dadurch sichtbar oder kriecht. Deshalb werden ruhende Körper eingefroren: + +1. Jeder Körper zählt, wie lange er ununterbrochen fast stillsteht + (`|v| ≤ 4 px/s` und `|ω| ≤ 0,1 rad/s`). +2. Körper, die sich direkt oder über andere berühren, bilden eine **Insel** (Union-Find über + die Kontakte). Wände verbinden keine Inseln, sonst wäre alles am Boden eine einzige Insel. +3. Eine Insel schläft ein, wenn **alle** ihre Körper seit `time_to_sleep` (0,5 s) ruhen. + Jeder Körper merkt sich dabei seine Insel (`Body.sleep_island`). +4. Schlafende Körper werden weder integriert noch aufgelöst – das spart auch Rechenzeit. +5. **Aufwecken – immer die ganze Insel:** bei Berührung durch einen wachen, beweglichen + Körper, beim Greifen, beim Umschalten der Schwerkraft und wenn ein Körper der Insel + gelöscht wird. + +Warum Inseln statt einzelner Körper? Sonst könnte ein schlafender Körper unter einem wachen +liegen. Der wache weckt ihn, beide ruhen, einer schläft früher ein, der andere weckt ihn +wieder – ein endloses Hin und Her. + +Warum wacht die ganze Insel auf? Zwei schlafende Körper werden nicht auf Kontakte +untereinander geprüft. Wird die unterste Kiste eines schlafenden Stapels geweckt und im +selben Schritt weggestoßen – oder im Pausenmodus weggezogen –, bemerkte sonst niemand, dass +die Kisten darüber ihre Stütze verloren haben. Sie schliefen in der Luft weiter. Weil alle +Körper, die einander berühren, gemeinsam eingeschlafen sind, genügt es, ihre Insel +gemeinsam zu wecken. + +Zusammen mit Schwellwert (Abschnitt 7), Slop (Abschnitt 10), Warm-Starting (Abschnitt 6) +und Rollwiderstand (Abschnitt 9) kommen Stapel und Haufen vollständig zur Ruhe. Ein Test +lässt 60 zufällige Körper fallen und verlangt, dass nach 8 s alle schlafen. + +**Grenze des Verfahrens:** Wie jeder Schlafmodus mit Geschwindigkeitsschwellen kann er +eine Lage einfrieren, die kein echtes Gleichgewicht ist. Ein Beispiel: Ein Kreis liegt +wenige Pixel außermittig auf einem anderen Kreis. Ist die Kontaktnormale steiler geneigt, +als der Rollwiderstand hält (tan α > μ_r), rollt er ab. Anfangs so langsam, dass er +0,5 s lang unter 4 px/s bleibt und einschläft. Er bleibt dann liegen, bis ihn etwas +berührt, und rollt erst danach weiter. Die Auswirkung beschränkt sich auf wenige Pixel. +Strengere Schwellen würden solche Fälle seltener machen, ließen dafür aber auch ruhende +Haufen später einschlafen. + +## 12. Tunneln verhindern + +Die Kollisionserkennung ist diskret: Sie sieht nur die Positionen am Ende jedes Schritts. + +- **Wände** sind Halbebenen und damit ohnehin tunnelsicher (Abschnitt 5). +- **Körper untereinander:** Fliegen zwei der kleinsten Körper (Radius `MIN_BODY_SIZE` = + 12 px) frontal aufeinander zu, darf ihre Relativbewegung pro Schritt die Summe ihrer + Radien nicht übersteigen. Sonst springen die Mittelpunkte aneinander vorbei: + + ```text + 2·v·dt < 2·MIN_BODY_SIZE ⇒ v < MIN_BODY_SIZE / dt = 1440 px/s + ``` + + Mit Sicherheitsfaktor 0,9 ergibt das `MAX_SPEED ≈ 1296 px/s`. Jede Geschwindigkeit wird + nach dem Lösen der Impulse darauf begrenzt, ebenso die Wurfgeschwindigkeit. Aus demselben + Grund ist bei Rechtecken keine *halbe* Kantenlänge kleiner als `MIN_BODY_SIZE`, jede + Seite ist also mindestens 24 px lang. +- Auch die Winkelgeschwindigkeit ist begrenzt (`MAX_SPEED / MIN_BODY_SIZE`). + +## 13. Ziehen und Werfen + +Ein gezogener Körper wird **nicht** an die Mausposition teleportiert. Dann könnte er durch +andere Körper und Wände hindurchgeschoben werden. Stattdessen setzt `DragController` vor +jedem Physikschritt die Geschwindigkeit in Richtung Ziel: + +```text +v = (Ziel − Position) · rate (rate = 60/s; rate·dt ≤ 1 verhindert Überschwingen) +``` + +Danach löst der Solver die Kontakte ganz normal. Das gezogene Objekt **kollidiert also +weiterhin**: Leichtere Körper werden weggeschoben, Wände und schwere Stapel bremsen es. Das +Ziel wird so geklemmt, dass der Körper ganz in die Welt passt. Gehaltene Körper unterliegen +keiner Schwerkraft ("die Hand trägt sie"), prallen nicht ab ("die Hand fängt den Stoß ab", +siehe [Abschnitt 7](#7-restitution-elastizität)) und schlafen nie ein. Beim Greifen wird +die Schlaf-Insel des Körpers geweckt. + +Folge der fehlenden Restitution: Schlägt man mit einem gehaltenen Objekt einen Ball, stößt +es ihn wie ein unelastischer Schläger. Der Ball fliegt deshalb etwas langsamer davon als +bei einem freien, elastischen Stoß (in einer Messung des Reviews um etwa 13 %). Dafür +steht ein gegen ein Hindernis gedrücktes Objekt ruhig. + +**Werfen:** Beim Loslassen erhält der Körper die Mausgeschwindigkeit der letzten Frames. +`VelocityTracker` glättet sie in zwei Stufen: + +1. **Zeitfenster:** mittlere Geschwindigkeit über die letzten 0,1 s (Weg / Zeit zwischen + ältester und neuester Probe) – unabhängig von der Bildrate. +2. **Exponentielle Glättung:** `v ← v + (v_roh − v)·(1 − e^(−Δt/τ))` mit τ = 0,05 s. Der + Exponentialterm macht die Glättung ebenfalls bildratenunabhängig. + +Hält man die Maus vor dem Loslassen still, klingt die Geschwindigkeit schnell ab, und das +Objekt fällt einfach herunter. Im Pausenmodus wird direkt verschoben und nicht geworfen. + +## 14. Parameterübersicht + +Alle Werte stehen in [`config.py`](../src/sandbox/config.py) und lassen sich dort anpassen. + +| Parameter | Wert | Bedeutung | +|---|---|---| +| `gravity` | (0, 980) px/s² | Schwerkraft | +| `time_step` | 1/120 s | fester Zeitschritt | +| `velocity_iterations` | 8 | Solver-Durchläufe pro Schritt | +| `position_iterations` | 2 | Durchläufe der Positionskorrektur | +| `restitution_threshold` | 50 px/s | darunter kein Abprallen | +| `penetration_slop` | 0,5 px | geduldete Resttiefe | +| `position_correction_factor` | 0,4 | Anteil der Korrektur pro Durchlauf | +| `max_position_correction` | 6 px | Obergrenze der Korrektur pro Durchlauf | +| `max_speed` | ≈ 1296 px/s | Tunnel-Schutz | +| `rolling_resistance` | 0,1 | Rollwiderstandsbeiwert μ_r | +| `sleep_linear_speed` / `sleep_angular_speed` | 4 px/s / 0,1 rad/s | Ruheschwellen | +| `time_to_sleep` | 0,5 s | Ruhezeit bis zum Einschlafen | +| `wall_friction` / `wall_restitution` | 0,6 / 0 | Wandmaterial | +| `max_wall_penetration` | 12 px | Notbremse an den Wänden (> `MAX_SPEED · dt`) | diff --git a/docs/PHYSIK_EINSTIEG.md b/docs/PHYSIK_EINSTIEG.md new file mode 100644 index 0000000..f0feb0e --- /dev/null +++ b/docs/PHYSIK_EINSTIEG.md @@ -0,0 +1,466 @@ +# Physik für Einsteiger – wie die Sandbox funktioniert + +Dieses Dokument erklärt die Physik der Sandbox Schritt für Schritt und ohne Vorwissen. +Jeder Abschnitt sagt, **was** physikalisch passiert, **warum** das Programm es so macht und +**wo** es im Code steht. Die kompakte Formelsammlung für Fortgeschrittene ist +[PHYSICS.md](PHYSICS.md). + +## Inhalt + +1. [Die Grundidee: Die Welt als Daumenkino](#1-die-grundidee-die-welt-als-daumenkino) +2. [Vektoren: Pfeile mit Richtung und Länge](#2-vektoren-pfeile-mit-richtung-und-länge) +3. [Position, Geschwindigkeit, Beschleunigung](#3-position-geschwindigkeit-beschleunigung) +4. [Masse und Trägheit](#4-masse-und-trägheit) +5. [Der Simulationsschritt: Euler-Verfahren](#5-der-simulationsschritt-euler-verfahren) +6. [Warum ein fester Zeitschritt?](#6-warum-ein-fester-zeitschritt) +7. [Kollisionen erkennen](#7-kollisionen-erkennen) +8. [Kollisionen auflösen: Impulse](#8-kollisionen-auflösen-impulse) +9. [Elastizität: Wie stark etwas abprallt](#9-elastizität-wie-stark-etwas-abprallt) +10. [Reibung](#10-reibung) +11. [Drehung und Rollen](#11-drehung-und-rollen) +12. [Viele Kontakte gleichzeitig](#12-viele-kontakte-gleichzeitig) +13. [Wenn Körper ineinanderstecken: Positionskorrektur](#13-wenn-körper-ineinanderstecken-positionskorrektur) +14. [Ruhe: Warum nichts zittert](#14-ruhe-warum-nichts-zittert) +15. [Tunneln: Warum nichts durch Wände fliegt](#15-tunneln-warum-nichts-durch-wände-fliegt) +16. [Ziehen und Werfen mit der Maus](#16-ziehen-und-werfen-mit-der-maus) +17. [Alles zusammen: ein kompletter Schritt](#17-alles-zusammen-ein-kompletter-schritt) +18. [Zum Ausprobieren](#18-zum-ausprobieren) + +--- + +## 1. Die Grundidee: Die Welt als Daumenkino + +Ein Computer kann Bewegung nicht "fließend" berechnen. Er macht es wie ein Daumenkino: Er +berechnet viele Einzelbilder dicht hintereinander. Die Sandbox berechnet die Welt +**120-mal pro Sekunde**. Zwischen zwei Bildern liegt also die Zeit + +```text +dt = 1/120 s ≈ 0,0083 s ("dt" = Delta t = kleines Zeitstück) +``` + +In jedem dieser Schritte fragt das Programm für jeden Körper: + +1. Welche Kräfte wirken? (Schwerkraft) +2. Wie ändert sich dadurch die Geschwindigkeit? +3. Wie ändert sich dadurch die Position? +4. Stößt der Körper irgendwo an? Wenn ja: Wie reagieren beide Körper? + +Mehr ist Physik-Simulation im Kern nicht. Der Rest dieses Dokuments erklärt die einzelnen +Punkte genauer. + +## 2. Vektoren: Pfeile mit Richtung und Länge + +Viele Größen in der Physik haben nicht nur einen Betrag, sondern auch eine **Richtung**. +"Der Ball fliegt 5 m/s schnell" reicht nicht – man muss wissen, *wohin*. Deshalb rechnet man +mit **Vektoren**: Pfeilen mit x- und y-Anteil. + +```text +v = (3, 4) bedeutet: 3 nach rechts, 4 nach unten (y zeigt wie am Bildschirm nach unten) +``` + +Die wichtigsten Operationen (alle in [`physics/vector.py`](../src/sandbox/physics/vector.py)): + +| Operation | Rechnung | Bedeutung | +|---|---|---| +| Addition `a + b` | (a.x+b.x, a.y+b.y) | Pfeile aneinanderhängen: "erst 3 nach rechts, dann 2 nach oben" | +| Skalierung `a * 2` | (2·a.x, 2·a.y) | Pfeil doppelt so lang, gleiche Richtung | +| Länge `a.length()` | √(x² + y²) | Satz des Pythagoras: wie lang ist der Pfeil? (3,4) hat Länge 5 | +| Normalisieren `a.normalized()` | a / Länge | Pfeil auf Länge 1 bringen – übrig bleibt nur die *Richtung* | +| Skalarprodukt `a.dot(b)` | a.x·b.x + a.y·b.y | "Wie viel von a zeigt in Richtung b?" | +| Kreuzprodukt `a.cross(b)` | a.x·b.y − a.y·b.x | "Wie stark dreht a um b herum?" (für Drehungen) | + +**Das Skalarprodukt ist der wichtigste Trick der ganzen Engine.** Ist `n` ein Pfeil der +Länge 1 (z. B. "senkrecht vom Boden weg"), dann gibt `v.dot(n)` an, wie schnell sich etwas +*in Richtung n* bewegt. Ein Ball mit `v = (5, 8)` (schräg nach unten) und der +Boden-Richtung `n = (0, 1)` (nach unten) liefert `v·n = 8`: Er fällt mit 8 Einheiten pro +Sekunde auf den Boden zu. Die 5 nach rechts spielen für den Aufprall keine Rolle. + +## 3. Position, Geschwindigkeit, Beschleunigung + +Drei Größen hängen wie eine Kette zusammen: + +| Größe | Einheit hier | Was sie ändert | +|---|---|---| +| Position x | px | – | +| Geschwindigkeit v | px/s | die Position: "so viele Pixel pro Sekunde" | +| Beschleunigung a | px/s² | die Geschwindigkeit: "pro Sekunde wird man so viel schneller" | + +**Schwerkraft** ist eine Beschleunigung. Auf der Erde sind das 9,81 m/s². Die Sandbox rechnet +in Pixeln und nimmt an, dass 100 px einem Meter entsprechen, also: + +```text +g = (0, 980) px/s² → pro Sekunde wird ein fallender Körper 980 px/s schneller +``` + +Beispiel: Ein Ball, der aus der Ruhe fällt, ist nach 0,5 s 490 px/s schnell und hat +½·g·t² = ½ · 980 · 0,25 ≈ 122 px zurückgelegt. Genau das prüft der Test +`test_free_fall_matches_half_g_t_squared`. + +## 4. Masse und Trägheit + +**Masse** sagt, wie schwer es ist, einen Körper zu beschleunigen. Ein Stoß, der einen +Tischtennisball wegschießt, bewegt eine Bowlingkugel kaum. In der Sandbox gilt: + +```text +Masse = Fläche × Dichte +Kreis: Fläche = π · r² +Rechteck: Fläche = Breite · Höhe +``` + +Ein doppelt so großer Kreis (doppelter Radius) hat die vierfache Fläche und damit die +vierfache Masse. + +**Trägheitsmoment** ist dasselbe Prinzip für Drehungen: Wie schwer ist es, einen Körper in +Drehung zu versetzen? Für eine Scheibe gilt `I = ½ · m · r²`. Masse, die weit außen sitzt, +dreht sich schwerer an. Deshalb steht dort das r². + +Rechtecke drehen sich in dieser Sandbox **nie** (die Aufgabe verlangt achsenparallele +Rechtecke). Programmtechnisch bekommen sie ein unendliches Trägheitsmoment. Dann kann kein +noch so großer Stoß sie drehen. + +**Trick mit dem Kehrwert:** Der Code speichert zusätzlich `inv_mass = 1/Masse`. Wände +bekommen `inv_mass = 0`, das heißt "unendlich schwer". So braucht man für Wände keine +Sonderfälle: Die Rechnung "Geschwindigkeitsänderung = Stoß · inv_mass" ergibt bei einer Wand +automatisch 0 – sie bewegt sich nicht. + +## 5. Der Simulationsschritt: Euler-Verfahren + +Wie kommt man von "Beschleunigung" zu "neuer Position"? Mit dem einfachsten Verfahren der +Welt, benannt nach Leonhard Euler: + +```text +neue Geschwindigkeit = alte Geschwindigkeit + Beschleunigung · dt +neue Position = alte Position + neue Geschwindigkeit · dt +``` + +Beispiel mit dt = 1/120 s für einen fallenden Ball: + +| Schritt | Geschwindigkeit (px/s) | Position y (px) | +|---|---|---| +| 0 | 0 | 100,00 | +| 1 | 0 + 980/120 = 8,17 | 100 + 8,17/120 = 100,07 | +| 2 | 16,33 | 100,20 | +| 3 | 24,50 | 100,41 | + +**Wichtiges Detail:** In der zweiten Zeile steht die *neue* Geschwindigkeit. Das heißt +"semi-impliziter Euler". Die naheliegende Variante mit der *alten* Geschwindigkeit hat einen +fiesen Fehler: Sie erzeugt bei Schwingungen und Pendelbewegungen langsam immer mehr +Energie, bis die Simulation "explodiert". Die hier verwendete Variante kostet genauso wenig, +bleibt aber stabil. Im Code: `World._integrate_velocities` und `World._integrate_positions` +in [`physics/world.py`](../src/sandbox/physics/world.py). + +## 6. Warum ein fester Zeitschritt? + +Das Bild wird z. B. 60-mal pro Sekunde gezeichnet, aber nicht exakt regelmäßig. Mal dauert +ein Bild 15 ms, mal 18 ms, und wenn Windows gerade etwas anderes tut, auch einmal 200 ms. + +Würde die Physik diese schwankende Zeit als dt benutzen, gäbe es zwei Probleme: + +- Die Simulation liefe auf jedem Rechner etwas anders. +- Bei einem Ruckler von 200 ms würde ein Ball in *einem* Schritt 200 px weit springen und + dabei vielleicht durch einen anderen Körper hindurch. + +Deshalb gibt es einen **Akkumulator** ("Zeitsparschwein") in +[`physics/timestep.py`](../src/sandbox/physics/timestep.py): Die echte Zeit wird +gesammelt und in festen Portionen von 1/120 s an die Physik ausgezahlt. Bei 60 Bildern pro +Sekunde sind das meist genau 2 Physikschritte pro Bild. Bleibt ein Rest, wartet er auf das +nächste Bild. + +Beim Zeichnen wird zusätzlich zwischen dem vorletzten und dem letzten Physikzustand +**interpoliert** (Zwischenwert gebildet). Dadurch wirkt die Bewegung flüssig, auch wenn +Physik und Bildschirm nicht im selben Takt laufen. + +## 7. Kollisionen erkennen + +Nach jedem Schritt muss das Programm herausfinden, welche Körper sich überlappen. Für jede +Berührung erzeugt es einen **Kontakt** mit drei Angaben: + +- **Normale n**: ein Pfeil der Länge 1, der von Körper A zu Körper B zeigt. In diese + Richtung werden die Körper auseinandergedrückt. +- **Eindringtiefe**: wie weit sich die Körper überlappen. +- **Kontaktpunkt**: wo sie sich berühren. + +### Kreis gegen Kreis – der einfachste Fall + +Zwei Kreise berühren sich, wenn der Abstand ihrer Mittelpunkte kleiner ist als die Summe +der Radien: + +```text + A ●───────● B Abstand d = 30, Radien 20 + 20 = 40 + ╲___╱ ╲___╱ → Überlappung 40 − 30 = 10 px +Normale n = (B − A) / d → Pfeil von A nach B, Länge 1 +``` + +### Kreis gegen Rechteck + +Man sucht den Punkt des Rechtecks, der dem Kreismittelpunkt am nächsten liegt. Dafür +"klemmt" man die Kreismitte in das Rechteck: Liegt sie links vom Rechteck, nimmt man den +linken Rand, liegt sie darüber, den oberen Rand usw. Ist dieser nächste Punkt näher als der +Radius, berühren sich die beiden. + +### Rechteck gegen Rechteck + +Zwei achsenparallele Rechtecke überlappen genau dann, wenn sie sich **sowohl** in +x-Richtung **als auch** in y-Richtung überlappen. Getrennt werden sie entlang der Richtung +mit der kleineren Überlappung – das ist der kürzeste Weg nach draußen. + +### Wände + +Boden, Decke und Seiten sind "Halbebenen": Alles hinter der Wandlinie gilt als Wand. Der +Vorteil: Auch ein Körper, der in einem Schritt komplett hinter die Wand gesprungen ist, wird +noch erkannt und zurückgeschoben. + +### Schneller suchen: Broadphase + +Bei 150 Körpern gibt es über 11 000 mögliche Paare – zu viele, um jedes genau zu prüfen. +Deshalb sortiert [`physics/broadphase.py`](../src/sandbox/physics/broadphase.py) alle Körper +von links nach rechts. Zwei Körper, die in x-Richtung weit auseinanderliegen, können sich +nicht berühren und werden gar nicht erst genau geprüft ("Sweep-and-Prune"). + +## 8. Kollisionen auflösen: Impulse + +Jetzt der wichtigste Teil. Zwei Körper berühren sich und bewegen sich aufeinander zu. Was +passiert? + +### Impuls – "Wucht" + +Der **Impuls** eines Körpers ist Masse mal Geschwindigkeit: `p = m · v`. Ein schwerer, +langsamer Lkw und ein leichter, schneller Ball können gleich viel "Wucht" haben. + +Ein **Stoß** (ebenfalls Impuls genannt, Formelzeichen P) ist eine sehr kurze, starke Kraft, +etwa beim Aufprall. Er ändert die Geschwindigkeit sofort: + +```text +Geschwindigkeitsänderung = Stoß / Masse +``` + +### Actio = Reactio + +Newtons drittes Gesetz: Wenn A gegen B drückt, drückt B genauso stark gegen A zurück. Der +Stoß P wirkt also auf B in Richtung der Normalen und auf A genau entgegengesetzt: + +```text +v_A neu = v_A − P · n / m_A +v_B neu = v_B + P · n / m_B +``` + +Dadurch bleibt der Gesamtimpuls erhalten. Was A an Wucht verliert, gewinnt B. + +### Wie groß muss der Stoß sein? + +Man schaut, wie schnell sich die beiden Körper aufeinander zu bewegen – entlang der +Normalen, also mit dem Skalarprodukt aus Abschnitt 2: + +```text +v_n = (v_B − v_A) · n (negativ = sie nähern sich an) +``` + +Dann sucht man den Stoß P, der diese Annäherung genau stoppt (bzw. umkehrt, siehe nächster +Abschnitt). Weil der Stoß beide Körper beeinflusst, hängt die nötige Stärke von beiden +Massen ab: + +```text +P = −(1 + e) · v_n / (1/m_A + 1/m_B) +``` + +Den Nenner `1/(1/m_A + 1/m_B)` nennt man **effektive Masse**. Bei einer Wand +(`1/m = 0`) ist sie einfach die Masse des Körpers. Bei zwei gleich schweren Körpern ist sie +halb so groß, weil der andere Körper mit ausweicht. + +**Rechenbeispiel:** Eine Kugel (m = 1) fliegt mit 10 px/s senkrecht auf den Boden, der +völlig unelastisch ist (e = 0). Dann ist v_n = −10, die effektive Masse ist 1 (Wand), und +der Stoß ist P = −(1 + 0) · (−10) / 1 = 10. Die neue Geschwindigkeit ist 10 − 10 = 0: Die +Kugel bleibt liegen. + +Im Code: [`physics/solver.py`](../src/sandbox/physics/solver.py), die ausführliche +Herleitung steht am Dateianfang. + +## 9. Elastizität: Wie stark etwas abprallt + +Die **Stoßzahl e** (in der Sidebar "Elastizität") sagt, wie viel der Geschwindigkeit nach +dem Aufprall übrig bleibt: + +| e | Verhalten | Beispiel | +|---|---|---| +| 0 | bleibt kleben, prallt nicht ab | Knetmasse | +| 0,5 | prallt mit halber Geschwindigkeit ab | Holzklotz | +| 0,8 | springt kräftig zurück | Gummiball | +| 1 | prallt ohne Verlust ab | ideale Superkugel | + +Daraus folgt eine schöne Regel: Ein Ball springt nach dem Aufprall auf **e²** seiner +Fallhöhe. Mit e = 0,8 sind das 0,64, also 64 % der Höhe. (Die Energie hängt vom Quadrat der +Geschwindigkeit ab, deshalb das Quadrat.) + +Zwei praktische Sonderregeln: + +- **Unter 50 px/s kein Abprallen.** Ein Körper, der auf dem Boden liegt, "fällt" wegen der + Schwerkraft in jedem Schritt minimal in den Boden. Würde er davon jedes Mal ein winziges + bisschen abprallen, würde er ständig zittern. +- **Gehaltene Objekte prallen nicht ab.** Die "Hand" fängt den Stoß ab – sonst würde ein + gegen eine Wand gedrücktes Objekt dauernd zurückfedern. + +## 10. Reibung + +Reibung bremst, wenn zwei Flächen aneinander entlangrutschen. Sie wirkt **quer zur +Normalen**, also entlang der Berührfläche (dieser Richtung sagt man "Tangente"). + +Das Coulomb-Gesetz: Die Reibung kann höchstens so stark sein wie der Andruck mal dem +**Reibungskoeffizienten μ**: + +```text +Reibung ≤ μ · Andruck +``` + +- Ist wenig Schwung da, reicht die Reibung, um die Bewegung ganz zu stoppen: **Haftreibung**. + Eine Kiste bleibt auf dem Boden stehen. +- Ist mehr Schwung da, rutscht die Kiste trotz Reibung weiter und wird gleichmäßig langsamer: + **Gleitreibung**. Sie bremst mit `μ · g`. Eine Kiste mit 300 px/s und μ ≈ 0,5 rutscht + etwa 90 px weit. + +Der Test `test_sliding_box_decelerates_with_coulomb_friction` prüft genau diesen Bremsweg. + +## 11. Drehung und Rollen + +Kreise können sich drehen. Die **Winkelgeschwindigkeit ω** (Omega) sagt, wie schnell, in +Radiant pro Sekunde (eine volle Umdrehung sind 2π ≈ 6,28 rad). + +Warum dreht sich ein Kreis überhaupt? Die Reibung greift nicht in der Mitte an, sondern +unten am Kontaktpunkt. Ein Stoß, der nicht durch die Mitte geht, erzeugt ein +**Drehmoment**, ähnlich wie ein Schraubenschlüssel. Genau dafür braucht man das +Kreuzprodukt aus Abschnitt 2: `Hebelarm × Kraft` ergibt, wie stark gedreht wird. + +**Rollen** heißt: Der Kontaktpunkt steht still, der Ball dreht sich genau passend zu seiner +Geschwindigkeit: + +```text +v = ω · r +``` + +Ein Experiment, das die Sandbox exakt nachrechnet: Stößt man eine Scheibe an, sodass sie +ohne Drehung über den Boden rutscht, bremst die Reibung sie und bringt sie gleichzeitig zum +Drehen. Irgendwann rollt sie – und dann hat sie **genau ⅔ ihrer Startgeschwindigkeit**, +unabhängig davon, wie stark die Reibung ist. Den kleinen Strich in jedem Kreis zeichnet das +Programm, damit man die Drehung sehen kann. + +**Rollwiderstand:** Ein perfekt rollender Ball würde nie anhalten, denn reines Rollen +verbraucht keine Reibungsenergie. In der Realität verformen sich Ball und Boden ein wenig, +das bremst. Die Sandbox bildet das mit einem kleinen Gegen-Drehmoment nach. Ohne diese +Bremse würde ein Rechteck, das auf einem Kreis liegt, darauf wie auf einer Rolle +davonfahren. + +## 12. Viele Kontakte gleichzeitig + +Liegt eine Kiste in einem Stapel, hat sie Kontakte nach oben und nach unten. Jeder Stoß an +einem Kontakt verändert die Situation an den anderen Kontakten. Man kann sie nicht einzeln +nacheinander "fertig" rechnen. + +Die Lösung heißt **Sequential Impulses** und funktioniert wie beim Aufräumen eines Zimmers +in mehreren Runden: Man geht alle Kontakte durch und korrigiert jeden ein bisschen. Dann +geht man sie noch einmal durch, weil die Korrekturen sich gegenseitig beeinflusst haben. +Nach **8 Runden** pro Schritt ist das Ergebnis sehr genau. + +Zwei Tricks machen das Verfahren stabil: + +- **Kontakte können nur drücken, nicht ziehen.** Die Summe aller Stöße an einem Kontakt darf + nie negativ werden, sonst würden Körper aneinanderkleben. +- **Warm-Starting:** Ein ruhender Stapel sieht im nächsten Schritt fast genauso aus wie + jetzt. Deshalb startet man mit den Stößen des letzten Schritts statt bei null und ist + sofort fast fertig. + +## 13. Wenn Körper ineinanderstecken: Positionskorrektur + +Weil nur in festen Schritten gerechnet wird, landet ein fallender Ball meist schon ein +kleines Stück *im* Boden, bevor der Aufprall erkannt wird. Die Stöße korrigieren nur die +Geschwindigkeit, nicht die Überlappung. Ohne Gegenmaßnahme würden Stapel langsam +ineinandersinken. + +Deshalb schiebt der Solver die Körper nach jedem Schritt ein Stück auseinander – direkt über +ihre Position, **ohne** ihnen Geschwindigkeit zu geben. Würde man sie stattdessen +"wegschubsen", würden stark überlappende Körper (zum Beispiel, wenn man ein Objekt mitten +in einen Haufen setzt) davonfliegen. + +Eine winzige Überlappung von 0,5 px (der "Slop") lässt man absichtlich stehen. So bleibt der +Kontakt im nächsten Schritt sicher bestehen, statt ständig zwischen "berührt" und "berührt +nicht" zu wechseln – auch das wäre sichtbares Zittern. + +## 14. Ruhe: Warum nichts zittert + +Auch mit allen Tricks bleiben winzige Restbewegungen übrig. Die Lösung ist der +**Schlafmodus** ([`physics/sleep.py`](../src/sandbox/physics/sleep.py)): + +1. Jeder Körper zählt, wie lange er schon fast stillsteht (langsamer als 4 px/s). +2. Körper, die sich berühren, bilden eine Gruppe (eine "Insel"). +3. Hat **die ganze Insel** eine halbe Sekunde lang fast stillgestanden, wird sie + eingefroren: Sie bewegt sich nicht mehr und kostet keine Rechenzeit. +4. Stößt etwas gegen einen schlafenden Körper, wacht **die ganze Insel** wieder auf. + +Warum die ganze Insel? Weckt man nur die unterste Kiste eines Stapels und zieht sie weg, +würde die Kiste darüber sonst in der Luft weiterschlafen. Genau diesen Fehler hat das +unabhängige Review gefunden, und genau so wurde er behoben. + +## 15. Tunneln: Warum nichts durch Wände fliegt + +Weil nur in Schritten gerechnet wird, könnte ein sehr schneller, kleiner Körper in einem +Schritt komplett über einen anderen hinwegspringen, ohne dass je eine Überlappung auftritt. +Das nennt man **Tunneln**. + +Zwei Gegenmaßnahmen: + +- **Wände sind unendlich dick** (Halbebenen). Ein Körper kann nicht "hinter" sie gelangen, + ohne erkannt zu werden. +- **Geschwindigkeitsgrenze:** Die kleinsten Körper haben einen Radius von 12 px. Solange + sich zwei davon pro Schritt zusammen weniger als ihre Radien-Summe bewegen, können ihre + Mittelpunkte nicht aneinander vorbeispringen. Daraus ergibt sich eine Höchstgeschwindigkeit + von etwa 1300 px/s, auf die jeder Körper begrenzt wird. + +## 16. Ziehen und Werfen mit der Maus + +Würde man ein gezogenes Objekt einfach an die Mausposition "teleportieren", könnte man es +durch Wände und andere Objekte hindurchschieben. Stattdessen bekommt es in jedem +Physikschritt die **Geschwindigkeit**, die es zur Maus bringt: + +```text +v = (Mausziel − Position) · 60 +``` + +Danach behandelt die Physik es wie jeden anderen Körper. Es schiebt leichte Objekte weg und +wird von Wänden gestoppt. + +**Werfen:** Beim Loslassen bekommt das Objekt die Geschwindigkeit der Maus. Die Maus +zittert aber und liefert unregelmäßige Werte. Deshalb wird geglättet +([`interaction/velocity_tracker.py`](../src/sandbox/interaction/velocity_tracker.py)): +erst ein Mittelwert über die letzten 0,1 Sekunden, dann noch eine weiche Glättung. Hält man +die Maus vor dem Loslassen still, sinkt die Geschwindigkeit auf fast null – das Objekt fällt +dann einfach herunter. + +## 17. Alles zusammen: ein kompletter Schritt + +So sieht ein einzelner Physikschritt (1/120 s) in `World.step` aus: + +1. Zustand merken (für das flüssige Zeichnen und das Abprallen). +2. Schwerkraft anwenden: `v = v + g · dt`. +3. Mögliche Kollisionspaare grob vorsortieren (Broadphase). +4. Kontakte genau bestimmen; schlafende Inseln wecken, die berührt werden. +5. Kontakte mit den Wänden bestimmen. +6. Stöße berechnen – 8 Runden lang, mit Abprallen, Reibung und Rollwiderstand. +7. Geschwindigkeit begrenzen (gegen Tunneln). +8. Bewegen: `x = x + v · dt`. +9. Restliche Überlappungen durch Verschieben abbauen. +10. Notbremse: Niemand steckt tiefer als 12 px in einer Wand. +11. Schlafmodus aktualisieren. + +## 18. Zum Ausprobieren + +Am besten versteht man Physik, indem man damit spielt: + +- Setze die **Elastizität auf 1** und lass einen Ball fallen: Er springt immer wieder genau + auf seine Starthöhe. Bei 0,5 wird jeder Sprung deutlich niedriger. +- Wirf einen kleinen Kreis mit **Dichte 0,2** gegen einen großen mit **Dichte 5**: Der + leichte prallt ab, der schwere bewegt sich kaum (Impulserhaltung). +- Baue einen Turm aus Rechtecken und ziehe die unterste Kiste weg: Der Rest fällt. +- Schalte mit **G** die Schwerkraft aus und wirf Objekte: Sie fliegen geradeaus, bis sie + etwas treffen (Newtons erstes Gesetz: Trägheit). +- Drücke **F1**: Gelbe Punkte zeigen die Kontaktpunkte, rote Striche die Normalen, graue + Umrandungen schlafende Körper. +- Ändere Werte in [`config.py`](../src/sandbox/config.py), zum Beispiel `gravity` oder + `rolling_resistance`, und beobachte die Wirkung. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..0a22a56 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,124 @@ +# Tests + +## Tests ausführen + +Voraussetzung ist die Entwicklungsumgebung aus dem [README](../README.md): + +```bash +pip install -r requirements-dev.txt +``` + +Alle Tests ausführen (Dauer etwa 15 s): + +```bash +pytest +``` + +Mit Testabdeckung und Liste der nicht abgedeckten Zeilen: + +```bash +pytest --cov=sandbox --cov-report=term-missing +``` + +Nur die schnellen Tests (ohne Stresstest und Smoke-Test): + +```bash +pytest -m "not slow" +``` + +Lint und Formatierung prüfen: + +```bash +ruff check src tests +``` + +```bash +ruff format --check src tests +``` + +Ruff prüft neben Stil und typischen Fehlern auch, dass alle Module, Klassen und +öffentlichen Funktionen einen Docstring (`D`) und Type Hints (`ANN`) haben und dass in +Vergleichen keine Magic Numbers stehen (`PLR2004`). Die Konfiguration steht in +`pyproject.toml`. + +## Headless-Modus + +UI- und Integrationstests laufen **ohne sichtbares Fenster**. `tests/conftest.py` setzt vor +dem ersten Import von pygame: + +```python +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") +``` + +SDL rendert dann in einen unsichtbaren Puffer und braucht weder Bildschirm noch Soundkarte. +Die Tests laufen so auch in CI oder über SSH. + +Die UI-Tests bedienen die Anwendung wie ein Mensch: Sie legen echte pygame-Events +(`MOUSEBUTTONDOWN`, `MOUSEMOTION`, `KEYDOWN` …) in die Event-Queue und rufen dann +`App.frame()` auf. Die Hilfsfunktionen dafür stehen in `tests/app/driver.py`. So wird die +ganze Kette getestet: Event → Sidebar/InputHandler → Werkzeuge → Physik → Zeichnen. + +Die Anwendung selbst lässt sich ebenfalls headless starten, etwa für Screenshots. +PowerShell: + +```powershell +$env:SDL_VIDEODRIVER = "dummy"; python -m sandbox --demo --screenshot bild.png --seconds 2 +``` + +Bash: + +```bash +SDL_VIDEODRIVER=dummy python -m sandbox --demo --screenshot bild.png --seconds 2 +``` + +## Was wird getestet? + +Die Erwartungswerte der Physik-Tests stammen aus der klassischen Mechanik – +Impulserhaltung, Stoßgesetz, Coulomb-Reibung, Rollbedingung – und nicht aus der +Implementierung selbst. + +### Physik (`tests/physics/`, ohne pygame) + +| Datei | Inhalt | +|---|---| +| `test_vector.py` | alle Vektoroperationen, Nullvektor-Normalisierung ohne NaN, Unveränderlichkeit, Hash/Gleichheit, Kopieren/Pickle | +| `test_bodies.py` | Fläche und Trägheitsmoment, **Masse = Fläche × Dichte**, statische Körper, Materialprüfung, Impulse mit/ohne Drehung, Energie und Impuls | +| `test_collision.py` | jede Formpaarung mit Normalenrichtung, Tiefe und Kontaktpunkt; Randfälle (gleicher Mittelpunkt, Kreismittelpunkt im Rechteck, knapp verfehlte Ecke, Körper hinter der Wand); Zufallstest mit über 500 Kontakten: Verschieben um die Tiefe trennt die Körper | +| `test_broadphase.py` | Sweep-and-Prune liefert dieselben Paare wie der Brute-Force-Vergleich | +| `test_solver.py` | **elastischer Stoß:** Impuls- und Energieerhaltung, Lehrbuchformel; e = 0: gemeinsame Geschwindigkeit; **Energieverlust bei e < 1** exakt nach ΔE = ½·μ·(1 − e²)·v²; Restitutionsschwelle; schiefer Stoß ohne Reibung; Coulomb-Reibung (Bremsweg v²/2μg); Rutschen → Rollen mit ⅔·v₀; Rollwiderstand; Auflagekraft = m·g·dt; Impulsgrenzen; **Trennen überlappender Körper ohne Geschwindigkeit** | +| `test_world.py` | **freier Fall ≈ ½·g·t²** (und exakt nach semi-implizitem Euler); **Ball bleibt am Boden liegen und verlässt die Welt nie**; Sprunghöhe = e² · Fallhöhe (1 %); ein vollkommen elastischer Ball springt über viele Sprünge immer wieder genau auf seine Fallhöhe; Energie steigt ohne äußere Kräfte nie; Stapel bleibt stehen; gemischter Haufen schläft ein; kein Tunneln bei Höchstgeschwindigkeit; Notbremse; Schlafen/Aufwecken, auch wenn die Basis eines schlafenden Stapels weggezogen oder weggestoßen wird (nichts schläft in der Luft weiter); Determinismus; **100 Körper × 1000 Schritte ohne NaN** (Stresstest) | +| `test_timestep.py` | Akkumulator, keine verlorenen Schritte durch Rundung, Obergrenze gegen die "Spiral of Death" | +| `test_sleep.py` | Union-Find, Inselbildung (Wände verbinden nicht), Einschlafen und Aufwachen nur als ganze Insel | + +### Interaktion (`tests/interaction/`, ohne pygame) + +| Datei | Inhalt | +|---|---| +| `test_velocity_tracker.py` | exakte Messung konstanter Bewegung, Unabhängigkeit von der Bildrate, Abklingen beim Stillhalten, Glättung von Zittern | +| `test_drag_and_tools.py` | Seitenverhältnis mit gleicher Fläche, Mindestgröße, Körper erzeugen, Obergrenze, Vorschaufarbe = echte Farbe; Greifen mit Versatz, Ziel in der Welt, Folgen, Geschwindigkeitsgrenze, **gezogener Körper schiebt andere weg**, gegen ein Hindernis gedrückter Körper zittert nicht, Werfen, Pause | +| `test_demo.py` | Beispielszene passt in die Welt, der Turm bleibt stehen | + +### Anwendung (`tests/app/`, headless mit pygame) + +| Datei | Inhalt | +|---|---| +| `test_app_integration.py` | **Objekt erstellen** (Klick, Ziehen aus der Sidebar, Abbruch), **greifen**, ziehen, **werfen**, Stillhalten ohne Wurf, gezogener Körper schiebt andere, bleibt in der Welt; **löschen** (Rechtsklick, Entf, Alles löschen); Pause, Schwerkraft, Regler (Klick, Ziehen, Mausrad), Tastenkürzel, Fenster schließen, Debug-Ansicht, Screenshot, Kommandozeile | +| `test_widgets.py` | Regler-Abbildung und Rasterung, deaktivierte Regler, Umschalter, Farbwahl, Zeichnen aller Widgets, Viewport, Winkel-Interpolation | +| `test_entry_points_and_edge_cases.py` | `python -m sandbox`, CLI ohne Optionen, Ende der Hauptschleife, Randfälle der Eingabe, Schrift-Fallback | +| `test_smoke.py` | **Smoke-Test:** Die echte Hauptschleife läuft 3 s headless, während ein zufällig klickender "Affe" Klicks, Ziehen, Rechtsklicks, Mausrad und Tasten sendet – ohne Exception, danach ein gültiger Zustand | + +### Architektur (`tests/test_architecture.py`) + +- Die Physik-Module und `config.py` importieren nur erlaubte Module (statische Prüfung über + den Syntaxbaum). +- `import sandbox.physics` lädt in einem frischen Interpreter kein pygame. + +## Testabdeckung + +Stand der letzten Messung: **219 Tests, 100 % Zeilen- und Branch-Abdeckung** über das ganze +Paket `sandbox` (inklusive UI), davon 100 % für `sandbox.physics`. + +Die hohe Abdeckung ist kein Selbstzweck. Entscheidend sind die physikalischen +Erwartungswerte: Ein Test, der nur Code ausführt, ohne das Ergebnis gegen die Physik zu +prüfen, wäre wertlos. diff --git a/docs/images/debug.png b/docs/images/debug.png new file mode 100644 index 0000000..86d959c Binary files /dev/null and b/docs/images/debug.png differ diff --git a/docs/images/screenshot.png b/docs/images/screenshot.png new file mode 100644 index 0000000..7ee1c39 Binary files /dev/null and b/docs/images/screenshot.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7b503de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "physics-sandbox" +version = "1.0.0" +description = "Interaktive 2D-Physik-Sandbox mit eigener Physik-Engine (Rendering mit pygame-ce)" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["pygame-ce>=2.5"] + +[project.scripts] +physics-sandbox = "sandbox.main:main" + +[tool.setuptools.packages.find] +where = ["src"] + +# --------------------------------------------------------------------------- ruff +[tool.ruff] +line-length = 100 +target-version = "py310" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", "W", # pycodestyle + "F", # pyflakes + "I", # Import-Sortierung + "B", # bugbear: typische Fehlerquellen + "UP", # moderne Python-Syntax + "SIM", # Vereinfachungen + "N", # Namenskonventionen + "C4", # Comprehensions + "PIE", + "RUF", + "D", # Docstrings für Module, Klassen und öffentliche Funktionen + "ANN", # Type Hints durchgehend + "PLR2004", # keine Magic Numbers in Vergleichen +] +ignore = [ + "D105", # Docstrings für Dunder-Methoden (__add__ usw.) wären reines Rauschen + "D107", # __init__ wird im Klassen-Docstring beschrieben + # Kommentare und Docstrings sind deutsch und nutzen bewusst Formelzeichen (·, ×, ², ω, μ): + "RUF001", "RUF002", "RUF003", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +# Testfunktionen sprechen über ihre Namen für sich; Zahlenwerte in Asserts sind gewollt. +"tests/**" = ["D101", "D102", "D103", "PLR2004"] + +# --------------------------------------------------------------------------- pytest +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "--strict-markers" +markers = [ + "slow: länger laufende Tests (Stresstest, Smoke-Test der Hauptschleife)", +] + +# --------------------------------------------------------------------------- coverage +[tool.coverage.run] +source = ["sandbox"] +branch = true + +[tool.coverage.report] +show_missing = true +exclude_also = [ + "if __name__ == .__main__.:", + "raise NotImplementedError", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..dd03d58 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +# Entwicklungs-Abhängigkeiten: Tests, Coverage, Linting/Formatierung. +-r requirements.txt + +pytest==9.1.1 +pytest-cov==7.1.0 +ruff==0.16.8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aedbdbb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Laufzeit-Abhängigkeiten der Physik-Sandbox. +# +# pygame-ce ist der API-kompatible Community-Fork von pygame (Import bleibt `import pygame`). +# Im Gegensatz zu pygame 2.6.1 gibt es dafür fertige Wheels für Python 3.14. +pygame-ce==2.5.8 + +# Das Projekt selbst (src-Layout) im Editable-Modus installieren, damit `python -m sandbox` +# ohne PYTHONPATH-Anpassung funktioniert. Der Pfad "." bezieht sich auf das aktuelle +# Verzeichnis – den Befehl daher im Projektordner ausführen. +-e . diff --git a/src/sandbox/__init__.py b/src/sandbox/__init__.py new file mode 100644 index 0000000..13478c2 --- /dev/null +++ b/src/sandbox/__init__.py @@ -0,0 +1,18 @@ +"""Physik-Sandbox: interaktive 2D-Physiksimulation mit eigener Physik-Engine. + +Aufbau: + +* :mod:`sandbox.physics` – reine Python-Physik (nur ``math``), ohne pygame testbar. +* :mod:`sandbox.interaction` – Werkzeug-Logik (Erzeugen, Ziehen, Werfen), ebenfalls + pygame-frei. +* :mod:`sandbox.ui`, :mod:`sandbox.renderer`, :mod:`sandbox.input_handler`, + :mod:`sandbox.main` – Fenster, Zeichnen, Eingaben und Hauptschleife mit pygame-ce. +""" + +import os + +# pygame-ce gibt beim ersten Import eine Werbezeile aus. Das Paket wird immer vor pygame +# importiert, deshalb lässt sie sich hier zentral abschalten (ohne pygame zu importieren). +os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") + +__version__ = "1.0.0" diff --git a/src/sandbox/__main__.py b/src/sandbox/__main__.py new file mode 100644 index 0000000..62ccb72 --- /dev/null +++ b/src/sandbox/__main__.py @@ -0,0 +1,5 @@ +"""Ermöglicht den Start mit ``python -m sandbox``.""" + +from sandbox.main import main + +raise SystemExit(main()) diff --git a/src/sandbox/config.py b/src/sandbox/config.py new file mode 100644 index 0000000..64c4062 --- /dev/null +++ b/src/sandbox/config.py @@ -0,0 +1,440 @@ +"""Zentrale Konfiguration der Physik-Sandbox. + +Alle einstellbaren Zahlenwerte des Projekts stehen in diesem Modul, damit der übrige Code +frei von "Magic Numbers" bleibt. Zusammengehörige Werte sind in unveränderlichen +Dataclasses gebündelt; Tests variieren einzelne Parameter mit :func:`dataclasses.replace`. + +Einheiten: Längen in Pixeln (px), Zeiten in Sekunden (s), Geschwindigkeiten in px/s. +Die y-Achse zeigt wie auf dem Bildschirm nach unten, die Schwerkraft wirkt also in +y. + +Das Modul importiert ausschließlich die Standardbibliothek, weil auch die pygame-freie +Physik-Schicht darauf zugreift. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +# ============================================================================= +# Grundgrößen der Simulation +# ============================================================================= + +#: Fester Physik-Zeitschritt in Sekunden (120 Hz), unabhängig von der Bildrate. +TIME_STEP: Final = 1.0 / 120.0 + +#: Kleinste und größte Halbausdehnung eines Körpers in px (Kreisradius bzw. halbe +#: Kantenlänge eines Rechtecks). +MIN_BODY_SIZE: Final = 12.0 +MAX_BODY_SIZE: Final = 80.0 + +#: Sicherheitsabstand (< 1) zur theoretischen Tunnel-Grenze, siehe MAX_SPEED. +TUNNELING_SAFETY_FACTOR: Final = 0.9 + +#: Geschwindigkeitsobergrenze in px/s (≈ 1296 px/s). +#: +#: Warum: Die Kollisionserkennung ist diskret, sie sieht nur die Positionen am Ende jedes +#: Schritts. Fliegen zwei der kleinsten Körper frontal aufeinander zu, darf ihre +#: Relativbewegung pro Schritt (2·v·dt) die Summe ihrer Radien (2·MIN_BODY_SIZE) nicht +#: überschreiten – sonst springen die Mittelpunkte aneinander vorbei ("Tunneln"). +#: Daraus folgt v < MIN_BODY_SIZE / dt. Die Wände sind Halbebenen und damit ohnehin +#: tunnelsicher: Ein Körper "hinter" der Wand wird immer als Durchdringung erkannt. +MAX_SPEED: Final = TUNNELING_SAFETY_FACTOR * MIN_BODY_SIZE / TIME_STEP + +#: Obergrenze der Winkelgeschwindigkeit in rad/s: So schnell dreht sich der kleinste Kreis, +#: wenn er mit MAX_SPEED rollt (ω = v / r). Verhindert numerisch explodierende Drehungen. +MAX_ANGULAR_SPEED: Final = MAX_SPEED / MIN_BODY_SIZE + +#: Toleranz für Gleitkomma-Vergleiche, z. B. "Vektor hat Länge 0". +NUMERIC_EPSILON: Final = 1e-9 + +#: Umrechnung Sekunden → Millisekunden (pygame misst Frame-Zeiten in ms). +MILLISECONDS_PER_SECOND: Final = 1000.0 + +#: Standard-Materialwerte für neue Körper. +DEFAULT_DENSITY: Final = 1.0 +DEFAULT_RESTITUTION: Final = 0.5 +DEFAULT_FRICTION: Final = 0.5 + + +@dataclass(frozen=True) +class PhysicsConfig: + """Parameter der Physik-Engine. + + Attributes: + gravity: Schwerkraftbeschleunigung (x, y) in px/s². 980 px/s² entspricht 9,8 m/s² + bei einem Maßstab von 100 px pro Meter. + time_step: Fester Zeitschritt in s. + velocity_iterations: Durchläufe des Impuls-Solvers pro Schritt. Mehr Durchläufe + lassen Stapel steifer werden, kosten aber Rechenzeit. + position_iterations: Durchläufe der Positionskorrektur pro Schritt. + restitution_threshold: Minimale Aufprallgeschwindigkeit in px/s, ab der Körper + abprallen. Darunter wird der Stoß als unelastisch behandelt, damit aufliegende + Körper nicht durch winzige Dauer-Hüpfer zittern. + penetration_slop: Geduldete Eindringtiefe in px. Wird nicht korrigiert, damit der + Kontakt zu einer Unterlage im nächsten Schritt sicher wieder erkannt wird – + sonst wechselten "Kontakt" und "kein Kontakt" ständig ab (Zittern). + position_correction_factor: Anteil der Eindringtiefe, der pro Durchlauf + korrigiert wird. Werte < 1 verteilen die Korrektur weich auf mehrere Schritte. + max_position_correction: Obergrenze der Korrektur pro Kontakt und Durchlauf in px. + Stark überlappende Körper (z. B. frisch erzeugt) werden so sanft getrennt. + max_speed: Geschwindigkeitsobergrenze in px/s (siehe MAX_SPEED). + max_angular_speed: Obergrenze der Winkelgeschwindigkeit in rad/s. + linear_damping: Lineare Dämpfung (Luftwiderstand) in 1/s. + angular_damping: Dämpfung der Drehung (Luftwiderstand) in 1/s. + rolling_resistance: Rollwiderstandsbeiwert μ_r (dimensionslos). An jedem Kontakt + bremst ein Moment von höchstens μ_r · Normalkraft · Radius die Drehung. Ohne + Rollwiderstand würde ein rollender Kreis nie anhalten, weil reines Rollen keine + Reibungsenergie verbraucht; ein Rechteck auf einem Kreis führe darauf wie auf + einer Rolle davon. + warm_starting: Startet den Solver mit den Impulsen des vorigen Schritts. + warm_start_min_alignment: Minimales Skalarprodukt zwischen alter und neuer + Kontaktnormale, damit der alte Impuls wiederverwendet wird. Dreht sich die + Normale stärker, passt der alte Impuls nicht mehr. + sleep_enabled: Schaltet den Schlafmodus für ruhende Körper ein. + sleep_linear_speed: Unterhalb dieser Geschwindigkeit (px/s) gilt ein Körper als + ruhend. + sleep_angular_speed: Unterhalb dieser Winkelgeschwindigkeit (rad/s) gilt ein Körper + als ruhend. + time_to_sleep: So lange (s) muss eine ganze Kontaktgruppe ruhen, bevor sie einschläft. + max_wall_penetration: Notbremse in px: Weiter darf ein Körper nie in eine Wand + ragen. Der Wert liegt über der größten Eindringtiefe, die ein frei fliegender + Körper in einem Schritt erreichen kann (MAX_SPEED · dt ≈ 10,8 px). Die Grenze + greift deshalb nie bei normalen Aufprallen – die löst der Solver –, sondern nur, + wenn ein Körper z. B. von einem gezogenen Objekt gegen die Wand gequetscht wird. + wall_restitution: Stoßzahl der Wände. 0 bedeutet: Beim Mischen mit max(e_A, e_B) + bestimmt allein das Material des Körpers, wie stark er abprallt. + wall_friction: Reibungskoeffizient der Wände. + """ + + gravity: tuple[float, float] = (0.0, 980.0) + time_step: float = TIME_STEP + velocity_iterations: int = 8 + position_iterations: int = 2 + restitution_threshold: float = 50.0 + penetration_slop: float = 0.5 + position_correction_factor: float = 0.4 + max_position_correction: float = 6.0 + max_speed: float = MAX_SPEED + max_angular_speed: float = MAX_ANGULAR_SPEED + linear_damping: float = 0.0 + angular_damping: float = 0.0 + rolling_resistance: float = 0.1 + warm_starting: bool = True + warm_start_min_alignment: float = 0.95 + sleep_enabled: bool = True + sleep_linear_speed: float = 4.0 + sleep_angular_speed: float = 0.1 + time_to_sleep: float = 0.5 + max_wall_penetration: float = MIN_BODY_SIZE + wall_restitution: float = 0.0 + wall_friction: float = 0.6 + + +#: Standardkonfiguration der Physik. +PHYSICS: Final = PhysicsConfig() + + +# ============================================================================= +# Fenster und Hauptschleife +# ============================================================================= + + +@dataclass(frozen=True) +class WindowConfig: + """Fenster und Hauptschleife. + + Attributes: + width: Fensterbreite in px. + height: Fensterhöhe in px. + title: Fenstertitel. + sidebar_width: Breite der Werkzeugleiste am linken Rand in px. Rechts davon + beginnt die Physikwelt. + fps_limit: Obergrenze der Bildrate. 60 Bilder/s genügen für flüssige Bewegung; die + Physik läuft unabhängig davon mit 120 Hz. + max_steps_per_frame: Höchstens so viele Physikschritte pro Bild (Schutz vor der + "Spiral of Death", siehe :class:`~sandbox.physics.timestep.FixedTimestep`). + stats_smoothing: Glättungsfaktor (0..1) der FPS-/Rechenzeit-Anzeige pro Bild. + screenshot_delay: Standard-Laufzeit (s) vor einem Bildschirmfoto mit + ``--screenshot``. + """ + + width: int = 1280 + height: int = 720 + title: str = "Physik-Sandbox" + sidebar_width: int = 260 + fps_limit: int = 60 + max_steps_per_frame: int = 8 + stats_smoothing: float = 0.1 + screenshot_delay: float = 3.0 + + +# ============================================================================= +# Werkzeuge und Interaktion +# ============================================================================= + + +@dataclass(frozen=True) +class InteractionConfig: + """Erzeugen, Ziehen und Werfen von Körpern. + + Attributes: + drag_response_rate: Wie schnell ein gezogener Körper der Maus folgt (1/s). Pro + Physikschritt wird der Anteil ``rate·dt`` des Abstands zum Ziel überbrückt – bei + 60/s und dt = 1/120 s also die Hälfte. Werte > 1/dt würden überschwingen. + throw_window: Zeitfenster (s) der Mausbewegung, aus dem die Wurfgeschwindigkeit + gemittelt wird ("die letzten Frames"). + throw_smoothing_time: Zeitkonstante (s) der zusätzlichen exponentiellen Glättung. + max_throw_speed: Obergrenze der Wurfgeschwindigkeit in px/s. + pick_tolerance: Zusätzlicher Rand (px), mit dem kleine Körper leichter zu greifen + sind. + max_bodies: Höchstzahl gleichzeitiger Körper (schützt die Bildrate). + size_wheel_step: Größenänderung (px) pro Mausrad-Raster. + min_size: Kleinste wählbare Größe (Radius bzw. halbe Kantenlänge) in px. + max_size: Größte wählbare Größe in px. + size_step: Rasterung des Größenreglers in px. + min_aspect_ratio: Kleinstes Seitenverhältnis Breite:Höhe eines Rechtecks. + max_aspect_ratio: Größtes Seitenverhältnis Breite:Höhe eines Rechtecks. + aspect_ratio_step: Rasterung des Seitenverhältnis-Reglers. + min_density: Kleinste wählbare Dichte. + max_density: Größte wählbare Dichte. + density_step: Rasterung des Dichte-Reglers. + max_restitution: Größte wählbare Elastizität. + restitution_step: Rasterung des Elastizitäts-Reglers. + default_size: Startwert der Größe in px. + default_aspect_ratio: Startwert des Seitenverhältnisses. + default_density: Startwert der Dichte. + default_restitution: Startwert der Elastizität. + friction: Reibungskoeffizient neu erzeugter Körper. + """ + + drag_response_rate: float = 60.0 + throw_window: float = 0.1 + throw_smoothing_time: float = 0.05 + max_throw_speed: float = MAX_SPEED + pick_tolerance: float = 4.0 + max_bodies: int = 300 + size_wheel_step: float = 2.0 + min_size: float = MIN_BODY_SIZE + max_size: float = MAX_BODY_SIZE + size_step: float = 1.0 + min_aspect_ratio: float = 0.5 + max_aspect_ratio: float = 2.0 + aspect_ratio_step: float = 0.05 + min_density: float = 0.2 + max_density: float = 5.0 + density_step: float = 0.1 + max_restitution: float = 1.0 + restitution_step: float = 0.05 + default_size: float = 28.0 + default_aspect_ratio: float = 1.5 + default_density: float = DEFAULT_DENSITY + default_restitution: float = DEFAULT_RESTITUTION + friction: float = DEFAULT_FRICTION + + +#: Standardkonfiguration der Interaktion. +INTERACTION: Final = InteractionConfig() + + +# ============================================================================= +# Darstellung +# ============================================================================= + +#: RGB-Farbe. +Color = tuple[int, int, int] + + +@dataclass(frozen=True) +class Theme: + """Farben der Oberfläche (dunkles Farbschema). + + Attributes: + world_background: Hintergrund der Physikwelt. + grid: Hilfsgitter der Welt. + world_border: Rahmen um die Welt. + sidebar_background: Hintergrund der Werkzeugleiste. + panel: Grundfarbe von Buttons und Reglern. + panel_hover: Button unter der Maus. + panel_active: Aktiver Button bzw. gewählte Option. + accent: Akzentfarbe (Reglerfüllung, aktive Umrandung). + text: Normaler Text. + text_dim: Nebensächlicher Text. + danger: Farbe für "Alles löschen". + highlight: Umrandung des Körpers unter der Maus. + held_outline: Umrandung des gezogenen Körpers. + throw_arrow: Pfeil der aktuellen Wurfgeschwindigkeit. + preview_alpha: Deckkraft (0–255) der Vorschau beim Erzeugen. + outline_shade: Faktor, um den die Umrandung dunkler ist als die Füllung. + debug_contact: Kontaktpunkte in der Debug-Ansicht. + debug_normal: Kontaktnormalen in der Debug-Ansicht. + debug_sleeping: Umrandung schlafender Körper in der Debug-Ansicht. + body_palette: Auswahl an Körperfarben. + """ + + world_background: Color = (22, 25, 32) + grid: Color = (32, 36, 46) + world_border: Color = (64, 71, 88) + sidebar_background: Color = (15, 17, 22) + panel: Color = (31, 35, 45) + panel_hover: Color = (42, 47, 60) + panel_active: Color = (48, 72, 102) + accent: Color = (96, 176, 240) + text: Color = (226, 230, 239) + text_dim: Color = (136, 145, 164) + danger: Color = (228, 92, 92) + highlight: Color = (255, 255, 255) + held_outline: Color = (255, 214, 102) + throw_arrow: Color = (255, 214, 102) + preview_alpha: int = 110 + outline_shade: float = 0.6 + debug_contact: Color = (255, 214, 10) + debug_normal: Color = (255, 110, 64) + debug_sleeping: Color = (120, 130, 150) + body_palette: tuple[Color, ...] = ( + (239, 83, 80), + (255, 167, 38), + (255, 213, 79), + (102, 187, 106), + (38, 198, 218), + (66, 133, 244), + (171, 71, 188), + (236, 64, 122), + ) + + +@dataclass(frozen=True) +class UiConfig: + """Schriften, Abstände und Maße der Oberfläche. + + Attributes: + font_names: Bevorzugte Systemschriften (die erste vorhandene wird genutzt). + title_font_size: Schriftgröße des Titels. + label_font_size: Schriftgröße von Beschriftungen. + small_font_size: Schriftgröße von Hinweisen und Werten. + padding: Innenabstand der Sidebar in px. + section_gap: Abstand zwischen Abschnitten in px. + row_gap: Abstand zwischen Elementen eines Abschnitts in px. + header_height: Höhe einer Abschnittsüberschrift in px. + button_height: Höhe von Buttons in px. + slider_height: Höhe eines Reglers (Beschriftung + Schiene) in px. + slider_track_height: Dicke der Reglerschiene in px. + slider_knob_radius: Radius des Reglerknopfs in px. + swatch_size: Kantenlänge eines Farbfelds in px. + swatch_gap: Abstand zwischen Farbfeldern in px. + spawn_tile_height: Höhe der Vorschau-Kachel in px. + corner_radius: Eckenradius von Buttons und Kacheln in px. + border_width: Linienstärke von Rahmen (Buttons, Umschalter, Weltrand) in px. + selection_ring_gap: Abstand des Auswahlrings um das gewählte Farbfeld in px. + selection_ring_width: Linienstärke des Auswahlrings in px. + grid_spacing: Abstand der Gitterlinien der Welt in px. + outline_width: Linienstärke von Körperumrandungen in px. + highlight_width: Linienstärke der Hervorhebung in px. + throw_arrow_time: Länge des Wurfpfeils als Flugzeit in s (Pfeil = v · Zeit). + debug_normal_length: Länge der gezeichneten Kontaktnormalen in px. + debug_contact_radius: Radius der gezeichneten Kontaktpunkte in px. + preview_scale_limit: Höchster Anteil der Kachelhöhe, den die Vorschau einnimmt. + marker_length: Länge des Drehmarkers eines Kreises als Anteil des Radius. + text_cache_size: So viele gerenderte Texte werden höchstens zwischengespeichert. + """ + + font_names: tuple[str, ...] = ("segoeui", "helveticaneue", "dejavusans", "arial") + title_font_size: int = 22 + label_font_size: int = 15 + small_font_size: int = 13 + padding: int = 16 + section_gap: int = 12 + row_gap: int = 6 + header_height: int = 20 + button_height: int = 32 + slider_height: int = 38 + slider_track_height: int = 4 + slider_knob_radius: int = 7 + swatch_size: int = 22 + swatch_gap: int = 3 + spawn_tile_height: int = 92 + corner_radius: int = 6 + border_width: int = 1 + selection_ring_gap: int = 2 + selection_ring_width: int = 2 + grid_spacing: int = 40 + outline_width: int = 2 + highlight_width: int = 3 + throw_arrow_time: float = 0.15 + debug_normal_length: float = 18.0 + debug_contact_radius: int = 3 + preview_scale_limit: float = 0.8 + marker_length: float = 0.85 + text_cache_size: int = 512 + + +#: Tastenbelegung: Tastenname (wie in ``pygame.key.key_code``) → Aktion. +KEY_BINDINGS: Final = ( + ("space", "toggle_pause"), + ("g", "toggle_gravity"), + ("c", "clear"), + ("delete", "delete_hovered"), + ("1", "shape_circle"), + ("2", "shape_box"), + ("f1", "toggle_debug"), + ("escape", "quit"), +) + + +@dataclass(frozen=True) +class DemoSceneConfig: + """Aufbau der Beispielszene (Start mit ``--demo``). + + x- und y-Positionen sind Anteile der Weltbreite bzw. -höhe, damit die Szene in jede + Fenstergröße passt. + + Attributes: + tower_x: Position des Rechteckturms. + tower_levels: Anzahl der Rechtecke im Turm. + box_size: Größe der Turm-Rechtecke in px. + box_aspect_ratio: Seitenverhältnis der Turm-Rechtecke. + pyramid_x: Mitte der Kreispyramide. + pyramid_rows: Anzahl der Reihen der Pyramide. + pyramid_circle_size: Radius der Pyramidenkreise in px. + pyramid_density: Dichte der Pyramidenkreise (schwer, damit sie stabil liegen). + pyramid_row_height: Vertikaler Reihenabstand in Radien. Dichteste Packung wäre + √3 ≈ 1,732; etwas mehr lässt einen hauchdünnen Spalt, damit nichts überlappt. + balls_x: Position des ersten Gummiballs. + balls_y: Starthöhe der Gummibälle. + ball_count: Anzahl der Gummibälle. + ball_size: Radius der Gummibälle in px. + ball_spacing: Abstand der Bälle in Radien. + ball_restitution: Elastizität der Gummibälle. + """ + + tower_x: float = 0.78 + tower_levels: int = 7 + box_size: float = 22.0 + box_aspect_ratio: float = 2.0 + pyramid_x: float = 0.45 + pyramid_rows: int = 5 + pyramid_circle_size: float = 20.0 + pyramid_density: float = 3.0 + pyramid_row_height: float = 1.75 + balls_x: float = 0.15 + balls_y: float = 0.2 + ball_count: int = 4 + ball_size: float = 16.0 + ball_spacing: float = 2.4 + ball_restitution: float = 0.8 + + +@dataclass(frozen=True) +class AppConfig: + """Gesamtkonfiguration der Anwendung.""" + + window: WindowConfig = WindowConfig() + physics: PhysicsConfig = PhysicsConfig() + interaction: InteractionConfig = InteractionConfig() + theme: Theme = Theme() + ui: UiConfig = UiConfig() + demo: DemoSceneConfig = DemoSceneConfig() + + +#: Standardkonfiguration der Anwendung. +APP: Final = AppConfig() diff --git a/src/sandbox/demo.py b/src/sandbox/demo.py new file mode 100644 index 0000000..6b695a9 --- /dev/null +++ b/src/sandbox/demo.py @@ -0,0 +1,51 @@ +"""Beispielszene zum Ausprobieren (Start mit ``python -m sandbox --demo``).""" + +from __future__ import annotations + +from dataclasses import replace + +from sandbox.config import DemoSceneConfig +from sandbox.interaction import BodyFactory, ShapeKind, ToolSettings +from sandbox.physics import Vector2 + + +def build_demo_scene(factory: BodyFactory, base: ToolSettings, scene: DemoSceneConfig) -> None: + """Baut einen Turm aus Rechtecken, eine Kreispyramide und ein paar Gummibälle auf. + + Args: + factory: Erzeugt die Körper (und fügt sie der Welt hinzu). + base: Ausgangseinstellungen (Farbe, Material), die je Gruppe abgewandelt werden. + scene: Aufbau der Szene. + """ + world = factory.world + floor = world.height + + boxes = replace( + base, shape=ShapeKind.BOX, size=scene.box_size, aspect_ratio=scene.box_aspect_ratio + ) + _, half_height = boxes.half_extents() + for level in range(scene.tower_levels): + y = floor - half_height - level * 2 * half_height + factory.create(boxes, Vector2(world.width * scene.tower_x, y)) + + circles = replace( + base, + shape=ShapeKind.CIRCLE, + size=scene.pyramid_circle_size, + density=scene.pyramid_density, + ) + radius = circles.size + rows = scene.pyramid_rows + for row in range(rows): + for column in range(rows - row): + # Jede Reihe ist um einen Radius eingerückt und liegt in den Mulden der unteren. + x = world.width * scene.pyramid_x + (2 * column + row - rows + 1) * radius + y = floor - radius - row * radius * scene.pyramid_row_height + factory.create(circles, Vector2(x, y)) + + balls = replace( + base, shape=ShapeKind.CIRCLE, size=scene.ball_size, restitution=scene.ball_restitution + ) + for index in range(scene.ball_count): + x = world.width * scene.balls_x + index * scene.ball_spacing * scene.ball_size + factory.create(balls, Vector2(x, world.height * scene.balls_y)) diff --git a/src/sandbox/input_handler.py b/src/sandbox/input_handler.py new file mode 100644 index 0000000..eb6c88c --- /dev/null +++ b/src/sandbox/input_handler.py @@ -0,0 +1,232 @@ +"""Übersetzt pygame-Eingaben in Aktionen von Sidebar, Welt und Werkzeugen. + +Maus-Events werden zuerst der Sidebar angeboten. Was sie nicht verbraucht und in der Welt +liegt, wird zur Werkzeug-Aktion: Linksklick auf einen Körper greift ihn, Linksklick ins +Leere erzeugt einen neuen Körper (und greift ihn sofort, sodass man ihn direkt werfen +kann), Rechtsklick löscht. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Callable +from functools import partial +from typing import Protocol + +import pygame + +from sandbox.config import KEY_BINDINGS, InteractionConfig +from sandbox.interaction import BodyFactory, DragController, ShapeKind, ToolSettings +from sandbox.physics import Body, Vector2, World +from sandbox.renderer import SpawnPreview +from sandbox.ui.sidebar import Sidebar, SidebarControls +from sandbox.viewport import Viewport + + +class AppControls(SidebarControls, Protocol): + """Aktionen der Anwendung, die per Tastatur ausgelöst werden können.""" + + def request_quit(self) -> None: + """Beendet die Anwendung nach dem aktuellen Frame.""" + ... + + def toggle_debug(self) -> None: + """Schaltet die Debug-Ansicht um.""" + ... + + +class InputHandler: + """Verteilt Maus- und Tastatur-Events. + + Args: + world: Die Physikwelt. + viewport: Lage der Welt auf dem Bildschirm. + drag: Steuerung für Greifen und Werfen. + factory: Erzeugt neue Körper. + tools: Aktuelle Werkzeug-Einstellungen. + config: Interaktionsparameter. + controls: Aktionen der Anwendung. + """ + + def __init__( + self, + *, + world: World, + viewport: Viewport, + drag: DragController, + factory: BodyFactory, + tools: ToolSettings, + config: InteractionConfig, + controls: AppControls, + ) -> None: + self.world = world + self.viewport = viewport + self.drag = drag + self.factory = factory + self.tools = tools + self.config = config + self.controls = controls + self.sidebar: Sidebar | None = None + self.mouse_pos: tuple[int, int] = pygame.mouse.get_pos() + self.hovered_body: Body | None = None + self.spawn_drag_active = False + self._cursor: int | None = None + actions: dict[str, Callable[[], None]] = { + "toggle_pause": controls.toggle_pause, + "toggle_gravity": controls.toggle_gravity, + "clear": controls.clear_world, + "delete_hovered": self.delete_hovered, + "shape_circle": partial(setattr, tools, "shape", ShapeKind.CIRCLE), + "shape_box": partial(setattr, tools, "shape", ShapeKind.BOX), + "toggle_debug": controls.toggle_debug, + "quit": controls.request_quit, + } + self._key_actions = { + pygame.key.key_code(key): actions[action] for key, action in KEY_BINDINGS + } + + # ------------------------------------------------------------------ Abfragen + + @property + def pointer_in_world(self) -> bool: + """Ob die Maus über der Welt steht.""" + return self.viewport.contains(self.mouse_pos) + + @property + def world_pointer(self) -> Vector2: + """Mausposition in Weltkoordinaten.""" + return self.viewport.to_world(self.mouse_pos) + + def spawn_preview(self) -> SpawnPreview | None: + """Vorschau des Objekts, das ein Klick an der aktuellen Mausposition erzeugen würde.""" + half_extents = self.tools.half_extents() + if self.spawn_drag_active: + center = (float(self.mouse_pos[0]), float(self.mouse_pos[1])) + elif ( + self.pointer_in_world + and self.drag.body is None + and self.hovered_body is None + and not self.factory.is_full + ): + clamped = self.world.clamp_inside(self.world_pointer, half_extents) + center = self.viewport.to_screen(clamped) + else: + return None + color = self.factory.color_for(self.tools) + return SpawnPreview(self.tools.shape, half_extents, color, center) + + # ------------------------------------------------------------------ Aktionen + + def begin_spawn_drag(self) -> None: + """Beginnt das Herausziehen eines neuen Objekts aus der Sidebar.""" + self.spawn_drag_active = True + + def delete_hovered(self) -> None: + """Löscht den Körper unter der Maus.""" + body = self._body_under_pointer() + if body is not None: + self.delete(body) + + def delete(self, body: Body) -> None: + """Entfernt einen Körper (und lässt ihn vorher los, falls er gehalten wird).""" + if self.drag.body is body: + self.drag.cancel() + if self.hovered_body is body: + self.hovered_body = None + self.world.remove_body(body) + + def forget_bodies(self) -> None: + """Vergisst Verweise auf Körper (nach "Alles löschen").""" + self.drag.cancel() + self.hovered_body = None + self.spawn_drag_active = False + + # ------------------------------------------------------------------ Events + + def handle_event(self, event: pygame.event.Event, time: float) -> None: + """Verarbeitet ein pygame-Event. + + Args: + event: Das Event. + time: Aktuelle Anwendungszeit in s (für die Wurfgeschwindigkeit). + """ + if event.type == pygame.QUIT: + self.controls.request_quit() + elif event.type == pygame.KEYDOWN: + action = self._key_actions.get(event.key) + if action is not None: + action() + elif event.type == pygame.MOUSEBUTTONDOWN: + self.mouse_pos = event.pos + if not self._offer_to_sidebar(event): + self._press_in_world(event.button, time) + elif event.type == pygame.MOUSEMOTION: + self.mouse_pos = event.pos + if not self._offer_to_sidebar(event): + self._move(time) + elif event.type == pygame.MOUSEBUTTONUP: + self.mouse_pos = event.pos + if event.button == pygame.BUTTON_LEFT: + # Loslassen beendet ein Ziehen immer – auch über der Sidebar. + self.drag.release(paused=self.controls.is_paused()) + self.spawn_drag_active = False + self._offer_to_sidebar(event) + elif event.type == pygame.MOUSEWHEEL and not self._offer_to_sidebar(event): + self._change_size(event.y) + + def update(self, time: float) -> None: + """Einmal pro Frame: Mausposition an den Wurf-Tracker melden, Hover bestimmen. + + Die Position wird auch ohne Maus-Event gemeldet: Steht die Maus still, kommen keine + Events – der Tracker muss aber merken, dass die Geschwindigkeit auf 0 fällt. + """ + if self.drag.body is not None: + self.drag.move(self.world_pointer, time) + self.hovered_body = None if self.drag.body is not None else self._body_under_pointer() + grabbing = self.drag.body is not None or self.hovered_body is not None + cursor = pygame.SYSTEM_CURSOR_HAND if grabbing else pygame.SYSTEM_CURSOR_ARROW + if cursor != self._cursor: + self._cursor = cursor + # Im Headless-Modus (Tests) gibt es keinen echten Mauszeiger. + with contextlib.suppress(pygame.error): + pygame.mouse.set_cursor(cursor) + + def _offer_to_sidebar(self, event: pygame.event.Event) -> bool: + """Bietet ein Event der Sidebar an; True, wenn sie es verbraucht hat.""" + if self.sidebar is None: + return False + return self.sidebar.handle_event(event, self.mouse_pos) + + def _press_in_world(self, button: int, time: float) -> None: + if not self.pointer_in_world: + return + pointer = self.world_pointer + if button == pygame.BUTTON_LEFT: + body = self._body_under_pointer() or self.factory.create(self.tools, pointer) + if body is not None: + self.drag.grab(body, pointer, time) + elif button == pygame.BUTTON_RIGHT: + body = self._body_under_pointer() + if body is not None: + self.delete(body) + + def _move(self, time: float) -> None: + if self.spawn_drag_active and self.pointer_in_world: + # Aus der Sidebar gezogenes Objekt erscheint, sobald die Maus die Welt erreicht, + # und hängt ab dann wie ein gegriffenes Objekt an der Maus. + self.spawn_drag_active = False + body = self.factory.create(self.tools, self.world_pointer) + if body is not None: + self.drag.grab(body, self.world_pointer, time) + if self.drag.body is not None: + self.drag.move(self.world_pointer, time) + + def _change_size(self, wheel_steps: int) -> None: + config = self.config + size = self.tools.size + wheel_steps * config.size_wheel_step + self.tools.size = min(max(size, config.min_size), config.max_size) + + def _body_under_pointer(self) -> Body | None: + if not self.pointer_in_world: + return None + return self.world.body_at(self.world_pointer, self.config.pick_tolerance) diff --git a/src/sandbox/interaction/__init__.py b/src/sandbox/interaction/__init__.py new file mode 100644 index 0000000..6424bbd --- /dev/null +++ b/src/sandbox/interaction/__init__.py @@ -0,0 +1,20 @@ +"""Werkzeug-Logik der Sandbox: Körper erzeugen, greifen, ziehen und werfen. + +Die Klassen hier arbeiten ausschließlich mit Weltkoordinaten und Zeitstempeln, nicht mit +pygame-Events. Dadurch lässt sich das Verhalten (z. B. die Glättung der +Wurfgeschwindigkeit) ohne Fenster testen; die Übersetzung von Maus-Events übernimmt +:mod:`sandbox.input_handler`. +""" + +from sandbox.interaction.drag import DragController +from sandbox.interaction.tools import BodyFactory, BodyStyle, ShapeKind, ToolSettings +from sandbox.interaction.velocity_tracker import VelocityTracker + +__all__ = [ + "BodyFactory", + "BodyStyle", + "DragController", + "ShapeKind", + "ToolSettings", + "VelocityTracker", +] diff --git a/src/sandbox/interaction/drag.py b/src/sandbox/interaction/drag.py new file mode 100644 index 0000000..473c49d --- /dev/null +++ b/src/sandbox/interaction/drag.py @@ -0,0 +1,116 @@ +"""Greifen, Ziehen und Werfen eines Körpers mit der Maus.""" + +from __future__ import annotations + +from sandbox.config import InteractionConfig +from sandbox.interaction.velocity_tracker import VelocityTracker +from sandbox.physics import ZERO, Body, Vector2, World + + +class DragController: + """Führt einen gegriffenen Körper zur Maus, ohne die Physik auszuhebeln. + + Der Körper wird *nicht* einfach an die Mausposition gesetzt – dann könnte er durch + andere Körper und Wände hindurchgeschoben werden. Stattdessen bekommt er vor jedem + Physikschritt die Geschwindigkeit, die ihn in Richtung Ziel bringt:: + + v = (Ziel − Position) · rate + + Danach löst der Solver ganz normal die Kontakte: Leichte Körper werden weggeschoben, + Wände und schwere Stapel bremsen den gezogenen Körper. Das gezogene Objekt kollidiert + also weiterhin. Beim Loslassen erhält es die geglättete Mausgeschwindigkeit + (:class:`~sandbox.interaction.velocity_tracker.VelocityTracker`) – das ist der Wurf. + + Args: + world: Die Welt, in der gezogen wird. + config: Folgegeschwindigkeit, Wurf-Glättung und -Obergrenze. + """ + + def __init__(self, world: World, config: InteractionConfig) -> None: + self.world = world + self.config = config + self.tracker = VelocityTracker(config.throw_window, config.throw_smoothing_time) + self._body: Body | None = None + self._offset = ZERO + self._target = ZERO + + @property + def body(self) -> Body | None: + """Der aktuell gehaltene Körper (oder ``None``).""" + return self._body + + @property + def target(self) -> Vector2: + """Zielposition des Schwerpunkts (Mausposition plus Griffversatz).""" + return self._target + + @property + def throw_velocity(self) -> Vector2: + """Geschwindigkeit, die der Körper beim Loslassen erhielte (px/s).""" + return self.tracker.velocity.clamped(self.config.max_throw_speed) + + def grab(self, body: Body, pointer: Vector2, time: float) -> None: + """Greift einen Körper an der Stelle ``pointer``. + + Der Versatz zwischen Griffpunkt und Schwerpunkt bleibt beim Ziehen erhalten, damit + der Körper nicht mit seinem Mittelpunkt zur Maus springt. + """ + self.cancel() + self._body = body + body.held = True + body.wake() + self._offset = body.position - pointer + self._target = body.position + self.tracker.reset(time, pointer) + + def move(self, pointer: Vector2, time: float) -> None: + """Meldet die aktuelle Mausposition (bei jedem Event und einmal pro Frame).""" + body = self._body + if body is None: + return + self.tracker.add_sample(time, pointer) + # Das Ziel so klemmen, dass der Körper ganz in die Welt passt; sonst würde er + # dauerhaft gegen die Wand gedrückt. + self._target = self.world.clamp_inside(pointer + self._offset, body.half_extents()) + + def apply(self, dt: float) -> None: + """Setzt vor einem Physikschritt die Geschwindigkeit Richtung Ziel.""" + body = self._body + if body is None: + return + # rate·dt ≤ 1, sonst würde der Körper über das Ziel hinausschießen und pendeln. + rate = min(self.config.drag_response_rate, 1.0 / dt) + velocity = (self._target - body.position) * rate + body.velocity = velocity.clamped(self.world.config.max_speed) + body.wake() + + def place(self) -> None: + """Setzt den Körper direkt aufs Ziel – nur im Pausenmodus, wenn keine Physik läuft.""" + body = self._body + if body is None: + return + body.teleport(self._target) + body.velocity = ZERO + + def release(self, *, paused: bool) -> Body | None: + """Lässt den Körper los und wirft ihn mit der geglätteten Mausgeschwindigkeit. + + Args: + paused: Im Pausenmodus wird nicht geworfen; der Körper bleibt liegen. + + Returns: + Den losgelassenen Körper (oder ``None``, wenn keiner gehalten wurde). + """ + body = self._body + if body is None: + return None + body.velocity = ZERO if paused else self.throw_velocity + self.cancel() + return body + + def cancel(self) -> None: + """Beendet das Halten, ohne die Geschwindigkeit zu ändern (z. B. beim Löschen).""" + if self._body is not None: + self._body.held = False + self._body.wake() + self._body = None diff --git a/src/sandbox/interaction/tools.py b/src/sandbox/interaction/tools.py new file mode 100644 index 0000000..f5ec96b --- /dev/null +++ b/src/sandbox/interaction/tools.py @@ -0,0 +1,137 @@ +"""Werkzeug-Einstellungen der Sidebar und das Erzeugen neuer Körper.""" + +from __future__ import annotations + +import math +import random +from dataclasses import dataclass +from enum import Enum + +from sandbox.config import MIN_BODY_SIZE, Color, InteractionConfig +from sandbox.physics import Body, Material, Vector2, World + + +class ShapeKind(Enum): + """Form neu erzeugter Körper.""" + + CIRCLE = "circle" + BOX = "box" + + +@dataclass +class ToolSettings: + """Aktuelle Auswahl in der Werkzeugleiste. + + Attributes: + shape: Form neuer Körper. + size: Radius bzw. "mittlere" halbe Kantenlänge in px. + aspect_ratio: Seitenverhältnis Breite:Höhe (nur für Rechtecke). + density: Dichte (Masse = Fläche × Dichte). + restitution: Elastizität (Stoßzahl). + color_index: Index in die Farbpalette oder ``None`` für eine Zufallsfarbe. + """ + + shape: ShapeKind + size: float + aspect_ratio: float + density: float + restitution: float + color_index: int | None = None + + @classmethod + def from_config(cls, config: InteractionConfig) -> ToolSettings: + """Startwerte aus der Konfiguration.""" + return cls( + shape=ShapeKind.CIRCLE, + size=config.default_size, + aspect_ratio=config.default_aspect_ratio, + density=config.default_density, + restitution=config.default_restitution, + ) + + def half_extents(self) -> tuple[float, float]: + """Halbe Breite und Höhe eines neuen Körpers. + + Für Rechtecke wird die Größe mit √(Seitenverhältnis) gestreckt bzw. gestaucht. So + bleibt die Fläche – und damit die Masse – beim Ändern des Seitenverhältnisses gleich, + nur die Form ändert sich. Ausnahme: Keine halbe Kantenlänge darf unter + MIN_BODY_SIZE fallen, weil die Geschwindigkeitsgrenze gegen Tunneln auf dieser + Mindestgröße beruht. Bei sehr kleinen Rechtecken wird die kurze Seite deshalb + angehoben, und die Fläche wächst etwas. + """ + if self.shape is ShapeKind.CIRCLE: + return (self.size, self.size) + stretch = math.sqrt(self.aspect_ratio) + return (max(self.size * stretch, MIN_BODY_SIZE), max(self.size / stretch, MIN_BODY_SIZE)) + + +@dataclass(frozen=True) +class BodyStyle: + """Darstellungsdaten eines Körpers (liegen in ``Body.user_data``). + + Attributes: + color: Füllfarbe. + """ + + color: Color + + +class BodyFactory: + """Erzeugt Körper gemäß den Werkzeug-Einstellungen und fügt sie der Welt hinzu. + + Args: + world: Zielwelt. + config: Grenzen und Materialwerte. + palette: Verfügbare Farben. + rng: Zufallsgenerator für Zufallsfarben (injizierbar für reproduzierbare Tests). + """ + + def __init__( + self, + world: World, + config: InteractionConfig, + palette: tuple[Color, ...], + rng: random.Random | None = None, + ) -> None: + self.world = world + self.config = config + self.palette = palette + self._rng = rng or random.Random() + # Die nächste Zufallsfarbe wird im Voraus gewürfelt, damit die Vorschau-Kachel + # genau die Farbe zeigt, die das nächste Objekt bekommt. + self._next_random_color = self._rng.choice(palette) + + @property + def is_full(self) -> bool: + """True, wenn die Höchstzahl an Körpern erreicht ist.""" + return len(self.world.bodies) >= self.config.max_bodies + + def color_for(self, settings: ToolSettings) -> Color: + """Farbe, die das nächste Objekt mit diesen Einstellungen bekommt.""" + if settings.color_index is None: + return self._next_random_color + return self.palette[settings.color_index] + + def create(self, settings: ToolSettings, position: Vector2) -> Body | None: + """Erzeugt einen Körper an ``position`` (wird bei Bedarf in die Welt geschoben). + + Returns: + Den neuen Körper oder ``None``, wenn die Höchstzahl erreicht ist. + """ + if self.is_full: + return None + half_width, half_height = settings.half_extents() + center = self.world.clamp_inside(position, (half_width, half_height)) + material = Material( + density=settings.density, + restitution=settings.restitution, + friction=self.config.friction, + ) + style = BodyStyle(self.color_for(settings)) + if settings.color_index is None: + self._next_random_color = self._rng.choice(self.palette) + if settings.shape is ShapeKind.CIRCLE: + body = Body.circle(center, half_width, material, user_data=style) + else: + body = Body.box(center, half_width, half_height, material, user_data=style) + return self.world.add_body(body) diff --git a/src/sandbox/interaction/velocity_tracker.py b/src/sandbox/interaction/velocity_tracker.py new file mode 100644 index 0000000..70a8416 --- /dev/null +++ b/src/sandbox/interaction/velocity_tracker.py @@ -0,0 +1,79 @@ +"""Geglättete Zeigergeschwindigkeit für das Werfen.""" + +from __future__ import annotations + +import math +from collections import deque + +from sandbox.physics import ZERO, Vector2 + + +class VelocityTracker: + """Schätzt, wie schnell sich die Maus zuletzt bewegt hat. + + Die Geschwindigkeit zwischen nur zwei aufeinanderfolgenden Frames ist sehr unruhig: + Maus-Events kommen unregelmäßig, und ein einzelner kurzer Frame liefert einen riesigen + Wert. Deshalb zwei Stufen: + + 1. **Zeitfenster:** Mittlere Geschwindigkeit über die letzten ``window`` Sekunden + (Weg / Zeit zwischen ältester und neuester Probe) – das entspricht "der Bewegung der + letzten Frames" und ist unabhängig von der Bildrate. + 2. **Exponentielle Glättung:** ``v ← v + (v_roh − v)·(1 − e^(−Δt/τ))``. Der + Exponentialterm macht die Glättung ebenfalls bildratenunabhängig: Zwei Frames à + 8 ms glätten genauso stark wie ein Frame à 16 ms. + + Hält man die Maus vor dem Loslassen still, sinkt die Geschwindigkeit schnell gegen 0 – + das Objekt fällt dann einfach herunter, statt mit einem veralteten Schwung davonzufliegen. + + Args: + window: Länge des Mittelungsfensters in s. + smoothing_time: Zeitkonstante τ der exponentiellen Glättung in s. + """ + + def __init__(self, window: float, smoothing_time: float) -> None: + if not (window > 0.0 and smoothing_time > 0.0): + raise ValueError("Fenster und Glättungszeit müssen positiv sein") + self.window = window + self.smoothing_time = smoothing_time + self._samples: deque[tuple[float, Vector2]] = deque() + self._velocity = ZERO + + @property + def velocity(self) -> Vector2: + """Aktuelle geglättete Geschwindigkeit in px/s.""" + return self._velocity + + def reset(self, time: float, position: Vector2) -> None: + """Beginnt eine neue Messung (z. B. beim Greifen eines Körpers).""" + self._samples.clear() + self._samples.append((time, position)) + self._velocity = ZERO + + def add_sample(self, time: float, position: Vector2) -> None: + """Nimmt eine neue Zeigerposition auf. + + Args: + time: Zeitstempel in s (monoton steigend). + position: Zeigerposition in Weltkoordinaten. + """ + samples = self._samples + if not samples: + self.reset(time, position) + return + last_time = samples[-1][0] + elapsed = time - last_time + if elapsed <= 0.0: + # Mehrere Events im selben Frame: nur die neueste Position zählt. + samples[-1] = (last_time, position) + return + samples.append((time, position)) + # Proben vor dem Fenster verwerfen – die jüngste davon bleibt als Bezugspunkt, + # damit das Fenster auch bei langen Frames nie leer ist. (Die neueste Probe liegt + # immer im Fenster, es bleiben also stets mindestens zwei Proben übrig.) + while len(samples) > 1 and samples[1][0] <= time - self.window: + samples.popleft() + + oldest_time, oldest_position = samples[0] + raw = (position - oldest_position) / (time - oldest_time) + blend = 1.0 - math.exp(-elapsed / self.smoothing_time) + self._velocity = self._velocity + (raw - self._velocity) * blend diff --git a/src/sandbox/main.py b/src/sandbox/main.py new file mode 100644 index 0000000..3cbeb9d --- /dev/null +++ b/src/sandbox/main.py @@ -0,0 +1,283 @@ +"""Einstiegspunkt und Hauptschleife der Physik-Sandbox. + +Ablauf eines Frames (:meth:`App.frame`): + +1. Events verarbeiten (Sidebar, Werkzeuge, Tastatur). +2. Die vergangene Zeit an den festen Zeitschritt geben; er liefert die Zahl fälliger + Physikschritte (bei 60 Bildern/s meist zwei Schritte à 1/120 s). +3. Vor jedem Physikschritt die Geschwindigkeit eines gezogenen Körpers setzen, dann + ``World.step`` ausführen. +4. Zeichnen – mit Interpolation zwischen den letzten beiden Physikzuständen. +""" + +from __future__ import annotations + +import argparse +import random +import time +from pathlib import Path + +import pygame + +from sandbox.config import APP, MILLISECONDS_PER_SECOND, AppConfig, Color +from sandbox.demo import build_demo_scene +from sandbox.input_handler import InputHandler +from sandbox.interaction import BodyFactory, DragController, ToolSettings +from sandbox.physics import FixedTimestep, World +from sandbox.renderer import Renderer, WorldOverlay +from sandbox.ui.sidebar import Sidebar +from sandbox.ui.style import UiStyle +from sandbox.viewport import Viewport + + +class FrameStats: + """Geglättete Bildrate und Physik-Rechenzeit für die Anzeige. + + Args: + smoothing: Anteil (0..1), mit dem ein neuer Messwert pro Frame einfließt. + """ + + def __init__(self, smoothing: float) -> None: + self.smoothing = smoothing + self.fps = 0.0 + self.physics_ms = 0.0 + + def update(self, frame_time: float, physics_seconds: float) -> None: + """Nimmt die Messwerte eines Frames auf.""" + if frame_time > 0.0: + fps = 1.0 / frame_time + self.fps = fps if self.fps == 0.0 else self.fps + (fps - self.fps) * self.smoothing + physics_ms = physics_seconds * MILLISECONDS_PER_SECOND + self.physics_ms += (physics_ms - self.physics_ms) * self.smoothing + + +class App: + """Die Anwendung: Fenster, Welt, Werkzeuge, Sidebar und Hauptschleife. + + Args: + config: Gesamtkonfiguration. + rng: Zufallsgenerator für Zufallsfarben (für reproduzierbare Tests). + """ + + def __init__(self, config: AppConfig = APP, *, rng: random.Random | None = None) -> None: + pygame.init() + self.config = config + window = config.window + self.screen = pygame.display.set_mode((window.width, window.height)) + pygame.display.set_caption(window.title) + self.clock = pygame.time.Clock() + + self.viewport = Viewport( + window.sidebar_width, 0, window.width - window.sidebar_width, window.height + ) + self.world = World(self.viewport.width, self.viewport.height, config.physics) + self.timestep = FixedTimestep(config.physics.time_step, window.max_steps_per_frame) + self.tools = ToolSettings.from_config(config.interaction) + self.factory = BodyFactory( + self.world, config.interaction, config.theme.body_palette, rng or random.Random() + ) + self.drag = DragController(self.world, config.interaction) + self.style = UiStyle(config.theme, config.ui) + self.input = InputHandler( + world=self.world, + viewport=self.viewport, + drag=self.drag, + factory=self.factory, + tools=self.tools, + config=config.interaction, + controls=self, + ) + self.sidebar = Sidebar( + pygame.Rect(0, 0, window.sidebar_width, window.height), + self.style, + self.tools, + config.interaction, + config.theme.body_palette, + self, + ) + self.input.sidebar = self.sidebar + self.renderer = Renderer(self.screen, self.viewport, self.style) + self.stats = FrameStats(window.stats_smoothing) + + self.paused = False + self.debug = False + self.running = True + self.time = 0.0 + + # ------------------------------------------------------------------ Steuerung + + def is_paused(self) -> bool: + """Ob die Simulation pausiert ist.""" + return self.paused + + def toggle_pause(self) -> None: + """Pausiert bzw. setzt die Simulation fort.""" + self.paused = not self.paused + # Angesammelte Zeit verwerfen, sonst holte die Physik nach der Pause Schritte nach. + self.timestep.reset() + + def gravity_enabled(self) -> bool: + """Ob die Schwerkraft wirkt.""" + return self.world.gravity_enabled + + def toggle_gravity(self) -> None: + """Schaltet die Schwerkraft an bzw. aus.""" + self.world.gravity_enabled = not self.world.gravity_enabled + + def clear_world(self) -> None: + """Entfernt alle Körper.""" + self.input.forget_bodies() + self.world.clear() + + def begin_spawn_drag(self) -> None: + """Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel.""" + self.input.begin_spawn_drag() + + def preview_color(self) -> Color: + """Farbe des nächsten Objekts.""" + return self.factory.color_for(self.tools) + + def status_lines(self) -> list[str]: + """Statuszeilen am unteren Rand der Sidebar.""" + limit = self.config.interaction.max_bodies + count = len(self.world.bodies) + full = " (voll)" if count >= limit else "" + return [ + f"Objekte: {count} / {limit}{full}", + f"{self.stats.fps:.0f} FPS · Physik {self.stats.physics_ms:.1f} ms", + "Rechtsklick: löschen · Rad: Größe", + "F1: Debug · Esc: Beenden", + ] + + def request_quit(self) -> None: + """Beendet die Hauptschleife nach dem aktuellen Frame.""" + self.running = False + + def toggle_debug(self) -> None: + """Schaltet die Debug-Ansicht (Kontakte, Schlafzustand) um.""" + self.debug = not self.debug + + def load_demo_scene(self) -> None: + """Ersetzt den Inhalt der Welt durch die Beispielszene.""" + self.clear_world() + build_demo_scene(self.factory, self.tools, self.config.demo) + + # ------------------------------------------------------------------ Hauptschleife + + def frame(self, frame_time: float) -> None: + """Verarbeitet Eingaben, rechnet die Physik und zeichnet ein Bild. + + Args: + frame_time: Real vergangene Zeit seit dem letzten Frame in s. + """ + self.time += frame_time + for event in pygame.event.get(): + self.input.handle_event(event, self.time) + self.input.update(self.time) + + physics_start = time.perf_counter() + if self.paused: + self.drag.place() # Im Pausenmodus wird direkt verschoben, ohne Physik. + else: + dt = self.timestep.step + for _ in range(self.timestep.advance(frame_time)): + self.drag.apply(dt) + self.world.step(dt) + self.stats.update(frame_time, time.perf_counter() - physics_start) + + self.draw() + pygame.display.flip() + + def draw(self) -> None: + """Zeichnet Welt, Sidebar und – obenauf – die Vorschau eines neuen Objekts.""" + overlay = WorldOverlay( + hovered=self.input.hovered_body, + held=self.drag.body, + throw_velocity=self.drag.throw_velocity, + paused=self.paused, + debug=self.debug, + debug_lines=self._debug_lines() if self.debug else [], + ) + alpha = 1.0 if self.paused else self.timestep.alpha + self.renderer.draw_world(self.world, alpha, overlay) + self.sidebar.draw(self.screen) + preview = self.input.spawn_preview() + if preview is not None: + self.renderer.draw_preview(preview) + + def _debug_lines(self) -> list[str]: + awake = sum(not body.sleeping for body in self.world.bodies) + return [ + f"Körper: {len(self.world.bodies)} (wach: {awake})", + f"Kontakte: {len(self.world.contacts)}", + f"Physik: {self.stats.physics_ms:.2f} ms/Frame · Schritt {self.world.step_count}", + ] + + def run(self, max_seconds: float | None = None) -> None: + """Führt die Hauptschleife aus, bis das Fenster geschlossen wird. + + Args: + max_seconds: Optional; beendet die Schleife nach dieser Laufzeit (für + Screenshots und den Smoke-Test). + """ + start = time.perf_counter() + self.clock.tick() # Zeitmessung ab jetzt, nicht ab dem Erzeugen der App + while self.running: + frame_time = self.clock.tick(self.config.window.fps_limit) / MILLISECONDS_PER_SECOND + self.frame(frame_time) + if max_seconds is not None and time.perf_counter() - start >= max_seconds: + break + + def save_screenshot(self, path: Path) -> None: + """Speichert den aktuellen Fensterinhalt als Bild.""" + path.parent.mkdir(parents=True, exist_ok=True) + pygame.image.save(self.screen, str(path)) + + def close(self) -> None: + """Gibt Fenster und pygame-Ressourcen frei.""" + pygame.quit() + + +def main(argv: list[str] | None = None) -> int: + """Startet die Sandbox (Kommandozeilen-Einstiegspunkt). + + Args: + argv: Kommandozeilenargumente (Standard: ``sys.argv[1:]``). + + Returns: + Exit-Code 0. + """ + parser = argparse.ArgumentParser( + prog="physics-sandbox", description="Interaktive 2D-Physik-Sandbox" + ) + parser.add_argument("--demo", action="store_true", help="mit einer Beispielszene starten") + parser.add_argument( + "--screenshot", + type=Path, + metavar="DATEI", + help="nach --seconds Sekunden ein Bildschirmfoto speichern und beenden", + ) + parser.add_argument( + "--seconds", + type=float, + default=APP.window.screenshot_delay, + help="Laufzeit vor dem Bildschirmfoto (Standard: %(default)s)", + ) + args = parser.parse_args(argv) + + app = App() + try: + if args.demo: + app.load_demo_scene() + if args.screenshot is not None: + app.run(max_seconds=args.seconds) + app.save_screenshot(args.screenshot) + else: + app.run() + finally: + app.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sandbox/physics/__init__.py b/src/sandbox/physics/__init__.py new file mode 100644 index 0000000..314b4ad --- /dev/null +++ b/src/sandbox/physics/__init__.py @@ -0,0 +1,36 @@ +"""Pygame-freie 2D-Physik-Engine. + +Die Physik verwendet ausschließlich das Standardmodul ``math`` (plus ``typing`` und +``dataclasses`` für Typangaben und Datenstrukturen). Ein Architektur-Test stellt sicher, +dass hier nie pygame oder eine andere Bibliothek importiert wird. + +Wichtigste Bausteine: + +* :class:`Vector2` – eigene Vektorklasse. +* :class:`Circle`, :class:`Box`, :class:`HalfPlane` – Formen. +* :class:`Body`, :class:`Material` – starre Körper. +* :class:`World` – verwaltet Körper und führt Simulationsschritte aus. +* :class:`FixedTimestep` – übersetzt Frame-Zeiten in feste Physikschritte. +""" + +from sandbox.physics.body import Body, Material +from sandbox.physics.collision import Contact, collide +from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape +from sandbox.physics.timestep import FixedTimestep +from sandbox.physics.vector import ZERO, Vector2 +from sandbox.physics.world import World + +__all__ = [ + "ZERO", + "Body", + "Box", + "Circle", + "Contact", + "FixedTimestep", + "HalfPlane", + "Material", + "Shape", + "Vector2", + "World", + "collide", +] diff --git a/src/sandbox/physics/body.py b/src/sandbox/physics/body.py new file mode 100644 index 0000000..db8b8a7 --- /dev/null +++ b/src/sandbox/physics/body.py @@ -0,0 +1,278 @@ +"""Starre Körper und ihre Materialeigenschaften.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import ClassVar + +from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape +from sandbox.physics.vector import ZERO, Vector2 + + +@dataclass(frozen=True, slots=True) +class Material: + """Stoffeigenschaften eines Körpers. + + Attributes: + density: Masse pro Fläche. Die Masse eines Körpers ist Fläche × Dichte, größere + Körper sind bei gleicher Dichte also schwerer. + restitution: Stoßzahl e ∈ [0, 1]. Verhältnis von Trenn- zu Aufprallgeschwindigkeit: + 0 = völlig unelastisch (kein Abprallen), 1 = vollkommen elastisch. + friction: Reibungskoeffizient μ ≥ 0 (Coulomb: Reibkraft ≤ μ · Normalkraft). + """ + + density: float + restitution: float + friction: float + + def __post_init__(self) -> None: + if self.density < 0.0: + raise ValueError(f"Dichte darf nicht negativ sein: {self.density}") + if not 0.0 <= self.restitution <= 1.0: + raise ValueError(f"Restitution muss in [0, 1] liegen: {self.restitution}") + if self.friction < 0.0: + raise ValueError(f"Reibung darf nicht negativ sein: {self.friction}") + + +class Body: + """Ein starrer Körper der Simulation. + + Dynamische Körper haben eine endliche Masse (Fläche × Dichte) und werden von Schwerkraft + und Kontakten bewegt. Statische Körper (die Wände) haben unendliche Masse; gespeichert + wird dafür die *inverse* Masse 0. Mit inversen Massen lassen sich beide Fälle ohne + Sonderbehandlung in dieselben Formeln einsetzen: Ein Impuls ändert die Geschwindigkeit + um ``Impuls · inv_mass`` – bei statischen Körpern also gar nicht. + + Attributes: + id: Eindeutige, fortlaufende Kennung (für Kontakt-Caches und deterministische + Reihenfolgen). + shape: Geometrie des Körpers. + material: Dichte, Restitution und Reibung. + position: Position des Schwerpunkts in px. + velocity: Geschwindigkeit des Schwerpunkts in px/s. + angle: Drehwinkel in rad (nur Kreise drehen sich). + angular_velocity: Winkelgeschwindigkeit in rad/s. + is_static: True für unbewegliche Körper (Wände). + mass: Masse (``math.inf`` bei statischen Körpern). + inv_mass: 1 / Masse (0 bei statischen Körpern). + inertia: Trägheitsmoment (``math.inf``, wenn sich der Körper nicht drehen kann). + inv_inertia: 1 / Trägheitsmoment (0, wenn sich der Körper nicht drehen kann). + sleeping: True, solange der Körper im Schlafmodus eingefroren ist. + sleep_time: Wie lange (s) der Körper schon ununterbrochen fast stillsteht. + sleep_island: Die Körper, mit denen dieser Körper gemeinsam eingeschlafen ist + (einschließlich ihm selbst), oder ``None``. Sie werden nur gemeinsam geweckt, + siehe :meth:`wake`. + held: True, solange der Körper mit der Maus gezogen wird. Gehaltene Körper + unterliegen keiner Schwerkraft und schlafen nie ein. + previous_position: Position zu Beginn des letzten Schritts (für die + Zeichen-Interpolation zwischen zwei Physikschritten). + previous_angle: Winkel zu Beginn des letzten Schritts. + previous_velocity: Geschwindigkeit zu Beginn des letzten Schritts, also vor + Schwerkraft und Kontakten. Daraus bestimmt der Solver die Aufprallgeschwindigkeit + für das Abprallen. + user_data: Beliebige Zusatzdaten der Anwendung (z. B. die Farbe). Die Physik + liest dieses Feld nie. + """ + + __slots__ = ( + "angle", + "angular_velocity", + "held", + "id", + "inertia", + "inv_inertia", + "inv_mass", + "is_static", + "mass", + "material", + "position", + "previous_angle", + "previous_position", + "previous_velocity", + "shape", + "sleep_island", + "sleep_time", + "sleeping", + "user_data", + "velocity", + ) + + _next_id: ClassVar[int] = 1 + + def __init__( + self, + shape: Shape, + position: Vector2, + material: Material, + *, + is_static: bool = False, + velocity: Vector2 = ZERO, + angular_velocity: float = 0.0, + user_data: object = None, + ) -> None: + self.id = Body._next_id + Body._next_id += 1 + + self.shape = shape + self.material = material + self.position = position + self.previous_position = position + self.angle = 0.0 + self.previous_angle = 0.0 + self.is_static = is_static + self.sleeping = False + self.sleep_time = 0.0 + self.sleep_island: list[Body] | None = None + self.held = False + self.user_data = user_data + + if is_static: + self.mass = math.inf + self.inv_mass = 0.0 + self.inertia = math.inf + self.inv_inertia = 0.0 + else: + if isinstance(shape, HalfPlane): + raise ValueError("Halbebenen können nur statische Körper sein") + mass = shape.area() * material.density + if not mass > 0.0: + raise ValueError("Dynamische Körper brauchen eine Dichte > 0") + self.mass = mass + self.inv_mass = 1.0 / mass + self.inertia = shape.moment_of_inertia(mass) + self.inv_inertia = 0.0 if math.isinf(self.inertia) else 1.0 / self.inertia + + # Statische Körper bewegen sich nie; Körper ohne Drehfreiheit drehen sich nie. + self.velocity = ZERO if is_static else velocity + self.previous_velocity = self.velocity + self.angular_velocity = angular_velocity if self.inv_inertia > 0.0 else 0.0 + + # ------------------------------------------------------------------ Fabrikmethoden + + @classmethod + def circle( + cls, + position: Vector2, + radius: float, + material: Material, + *, + velocity: Vector2 = ZERO, + user_data: object = None, + ) -> Body: + """Erzeugt einen dynamischen Kreis.""" + return cls(Circle(radius), position, material, velocity=velocity, user_data=user_data) + + @classmethod + def box( + cls, + position: Vector2, + half_width: float, + half_height: float, + material: Material, + *, + velocity: Vector2 = ZERO, + user_data: object = None, + ) -> Body: + """Erzeugt ein dynamisches achsenparalleles Rechteck.""" + return cls( + Box(half_width, half_height), position, material, velocity=velocity, user_data=user_data + ) + + @classmethod + def wall(cls, normal: Vector2, offset: float, material: Material) -> Body: + """Erzeugt eine statische Wand (Halbebene ``normal·p ≥ offset`` ist frei).""" + return cls(HalfPlane(normal, offset), ZERO, material, is_static=True) + + # ------------------------------------------------------------------ Abfragen + + @property + def can_rotate(self) -> bool: + """True, wenn Drehimpulse den Körper drehen können (nur dynamische Kreise).""" + return self.inv_inertia > 0.0 + + def half_extents(self) -> tuple[float, float]: + """Halbe Breite und Höhe der umschließenden achsenparallelen Box.""" + return self.shape.half_extents() + + def bounds(self) -> tuple[float, float, float, float]: + """Umschließende Box als ``(min_x, min_y, max_x, max_y)``.""" + half_x, half_y = self.shape.half_extents() + x = self.position.x + y = self.position.y + return (x - half_x, y - half_y, x + half_x, y + half_y) + + def contains_point(self, point: Vector2, tolerance: float = 0.0) -> bool: + """Prüft, ob ein Weltpunkt im Körper liegt (``tolerance`` vergrößert den Körper).""" + return self.shape.contains(point - self.position, tolerance) + + def kinetic_energy(self) -> float: + """Bewegungsenergie ``½·m·v² + ½·I·ω²`` (0 für statische Körper).""" + if self.is_static: + return 0.0 + energy = 0.5 * self.mass * self.velocity.length_squared() + if self.can_rotate: + energy += 0.5 * self.inertia * self.angular_velocity * self.angular_velocity + return energy + + def momentum(self) -> Vector2: + """Impuls ``p = m·v``.""" + if self.is_static: + return ZERO + return self.velocity * self.mass + + # ------------------------------------------------------------------ Zustandsänderungen + + def apply_impulse(self, impulse: Vector2, offset: Vector2 = ZERO) -> None: + """Wendet einen Impuls an einem Punkt an. + + Args: + impulse: Impuls (Kraftstoß) in Masse·px/s. + offset: Angriffspunkt relativ zum Schwerpunkt. Ein Impuls, der nicht durch den + Schwerpunkt geht, erzeugt zusätzlich einen Drehimpuls ``offset × impulse``. + """ + if self.is_static: + return + self.velocity = self.velocity + impulse * self.inv_mass + self.angular_velocity += self.inv_inertia * offset.cross(impulse) + + def wake(self) -> None: + """Weckt den Körper – und alle Körper, mit denen er eingeschlafen ist. + + Warum die ganze Insel? Zwei schlafende Körper werden nicht auf Kontakte + untereinander geprüft. Würde nur die unterste Kiste eines schlafenden Stapels + geweckt und im selben Schritt weggestoßen (oder in der Pause weggezogen), bemerkte + niemand, dass die Kisten darüber ihre Stütze verloren haben – sie schwebten. + """ + island = self.sleep_island + for body in island if island is not None else (self,): + body.sleeping = False + body.sleep_time = 0.0 + body.sleep_island = None + + def put_to_sleep(self, island: list[Body] | None = None) -> None: + """Friert den Körper ein: keine Bewegung, bis ihn etwas aufweckt. + + Args: + island: Die Körper, die gemeinsam mit diesem einschlafen (einschließlich + ihm selbst). Weckt später einer von ihnen auf, wachen alle auf. + """ + self.sleeping = True + self.sleep_island = island + self.velocity = ZERO + self.previous_velocity = ZERO + self.angular_velocity = 0.0 + # Ohne diese Angleichung würde die Zeichen-Interpolation einen längst ruhenden + # Körper noch zwischen zwei alten Positionen hin- und herschieben. + self.previous_position = self.position + self.previous_angle = self.angle + + def teleport(self, position: Vector2) -> None: + """Setzt die Position direkt (ohne Interpolations-Schlieren) und weckt den Körper.""" + self.position = position + self.previous_position = position + self.wake() + + def __repr__(self) -> str: + state = " schläft" if self.sleeping else "" + return f"Body(id={self.id}, {self.shape!r}, position={self.position!r}{state})" diff --git a/src/sandbox/physics/broadphase.py b/src/sandbox/physics/broadphase.py new file mode 100644 index 0000000..c686f17 --- /dev/null +++ b/src/sandbox/physics/broadphase.py @@ -0,0 +1,51 @@ +"""Grobe Vorauswahl möglicher Kollisionspaare ("Broadphase") per Sweep-and-Prune. + +Warum: Jedes Paar von n Körpern genau zu prüfen kostet n·(n−1)/2 Tests – bei 150 Körpern +über 11 000 pro Schritt, 120-mal pro Sekunde. In reinem Python ist das zu langsam. + +Sweep-and-Prune sortiert die umschließenden Boxen nach ihrer linken Kante und läuft einmal +von links nach rechts. Für jede Box müssen nur die Nachfolger geprüft werden, deren linke +Kante noch vor ihrer rechten Kante liegt; alle weiteren beginnen noch weiter rechts und +können nicht überlappen. Der Aufwand sinkt so auf etwa O(n·log n + k) für k tatsächlich +benachbarte Paare. Da sich die Körper zwischen zwei Schritten kaum bewegen, ist die Liste +fast sortiert, und Pythons Timsort sortiert sie besonders schnell. +""" + +from __future__ import annotations + +from sandbox.physics.body import Body + + +def find_candidate_pairs(bodies: list[Body]) -> list[tuple[Body, Body]]: + """Findet alle Körperpaare, deren umschließende Boxen sich überlappen oder berühren. + + Paare aus zwei schlafenden Körpern werden übersprungen: Sie bewegen sich nicht, ihr + Kontakt ändert sich also nicht. Jedes Paar ist nach ``id`` geordnet, damit die + Reihenfolge (und damit die ganze Simulation) reproduzierbar ist. + + Args: + bodies: Die dynamischen Körper der Welt. + + Returns: + Kandidatenpaare für die genaue Prüfung mit :func:`~sandbox.physics.collision.collide`. + """ + entries = sorted(((body.bounds(), body) for body in bodies), key=_left_edge) + pairs: list[tuple[Body, Body]] = [] + count = len(entries) + for i in range(count): + (_, min_y, max_x, max_y), body = entries[i] + for j in range(i + 1, count): + (other_min_x, other_min_y, _, other_max_y), other = entries[j] + if other_min_x > max_x: + break # Alle weiteren Boxen beginnen noch weiter rechts. + if other_min_y > max_y or other_max_y < min_y: + continue + if body.sleeping and other.sleeping: + continue + pairs.append((body, other) if body.id < other.id else (other, body)) + return pairs + + +def _left_edge(entry: tuple[tuple[float, float, float, float], Body]) -> float: + """Sortierschlüssel: linke Kante der umschließenden Box.""" + return entry[0][0] diff --git a/src/sandbox/physics/collision.py b/src/sandbox/physics/collision.py new file mode 100644 index 0000000..f81c48c --- /dev/null +++ b/src/sandbox/physics/collision.py @@ -0,0 +1,266 @@ +"""Kollisionserkennung ("Narrowphase"): Berühren sich zwei Körper, und wenn ja, wie? + +Jede Testfunktion liefert entweder ``None`` (keine Berührung) oder einen :class:`Contact` +mit drei Informationen, die die Kollisionsauflösung braucht: + +* **Normale** n – Einheitsvektor, der von Körper A zu Körper B zeigt. Entlang n werden + die Körper auseinandergedrückt. +* **Eindringtiefe** – wie weit sich die Körper entlang n überlappen (≥ 0). +* **Kontaktpunkt** – wo die Kraft angreift. Er bestimmt die Hebelarme für die Drehung. + +Konvention: :func:`collide` ordnet die Körper so, dass Körper A die "einfachere" Form hat +(Kreis vor Rechteck vor Wand). Für ein Körperpaar ist die Orientierung dadurch in jedem +Schritt gleich, was das Warm-Starting des Solvers (Wiederverwenden alter Impulse) erlaubt. +""" + +from __future__ import annotations + +import math +from typing import Final + +from sandbox.config import NUMERIC_EPSILON +from sandbox.physics.body import Body +from sandbox.physics.shapes import Box, Circle, HalfPlane +from sandbox.physics.vector import Vector2 + +#: Ersatznormale, wenn zwei Kreismittelpunkte exakt aufeinanderliegen und die Richtung +#: daher unbestimmt ist. Jede Richtung wäre richtig; eine feste macht das Ergebnis +#: reproduzierbar. (0, −1) schiebt Körper B nach oben, A nach unten. +COINCIDENT_NORMAL: Final = Vector2(0.0, -1.0) + +#: Reihenfolge der Formen für die kanonische Anordnung in :func:`collide`. +_SHAPE_ORDER: Final = {Circle: 0, Box: 1, HalfPlane: 2} + + +class Contact: + """Berührung zweier Körper in einem Simulationsschritt. + + Attributes: + body_a: Erster Körper. + body_b: Zweiter Körper. + normal: Einheitsnormale von A nach B. + penetration: Eindringtiefe entlang der Normalen in px (≥ 0). + point: Kontaktpunkt in Weltkoordinaten. + normal_impulse: Vom Solver aufgebrachter Normalimpuls (Ergebnis, ≥ 0). + tangent_impulse: Vom Solver aufgebrachter Reibungsimpuls (Ergebnis). + """ + + __slots__ = ( + "body_a", + "body_b", + "normal", + "normal_impulse", + "penetration", + "point", + "tangent_impulse", + ) + + def __init__( + self, body_a: Body, body_b: Body, normal: Vector2, penetration: float, point: Vector2 + ) -> None: + self.body_a = body_a + self.body_b = body_b + self.normal = normal + self.penetration = penetration + self.point = point + self.normal_impulse = 0.0 + self.tangent_impulse = 0.0 + + @property + def key(self) -> tuple[int, int]: + """Schlüssel des Körperpaares, um Kontakte über Schritte hinweg wiederzuerkennen.""" + return (self.body_a.id, self.body_b.id) + + def __repr__(self) -> str: + return ( + f"Contact({self.body_a.id}->{self.body_b.id}, normal={self.normal!r}, " + f"penetration={self.penetration:.3f})" + ) + + +def collide(first: Body, second: Body) -> Contact | None: + """Prüft zwei beliebige Körper auf Berührung. + + Die Körper werden vorher kanonisch angeordnet (siehe Moduldokumentation). Das Ergebnis + kann ``body_a`` und ``body_b`` daher in vertauschter Reihenfolge enthalten. + + Returns: + Den Kontakt oder ``None``, wenn sich die Körper nicht berühren. + """ + body_a, body_b = first, second + if _SHAPE_ORDER[type(body_b.shape)] < _SHAPE_ORDER[type(body_a.shape)]: + body_a, body_b = body_b, body_a + + shape_a = body_a.shape + shape_b = body_b.shape + if isinstance(shape_a, Circle): + if isinstance(shape_b, Circle): + return collide_circles(body_a, body_b) + if isinstance(shape_b, Box): + return collide_circle_box(body_a, body_b) + return collide_circle_half_plane(body_a, body_b) + if isinstance(shape_a, Box): + if isinstance(shape_b, Box): + return collide_boxes(body_a, body_b) + return collide_box_half_plane(body_a, body_b) + return None # Zwei Wände kollidieren nie miteinander. + + +def collide_circles(body_a: Body, body_b: Body) -> Contact | None: + """Kreis gegen Kreis. + + Zwei Kreise überlappen, wenn der Mittelpunktabstand kleiner als die Summe der Radien + ist. Die Normale zeigt entlang der Verbindungslinie der Mittelpunkte. + """ + radius_a = body_a.shape.radius + radius_b = body_b.shape.radius + dx = body_b.position.x - body_a.position.x + dy = body_b.position.y - body_a.position.y + distance_sq = dx * dx + dy * dy + radius_sum = radius_a + radius_b + if distance_sq > radius_sum * radius_sum: + return None + + if distance_sq > NUMERIC_EPSILON * NUMERIC_EPSILON: + distance = math.sqrt(distance_sq) + normal = Vector2(dx / distance, dy / distance) + else: + distance = 0.0 + normal = COINCIDENT_NORMAL + penetration = radius_sum - distance + # Kontaktpunkt: Mitte des überlappenden Bereichs auf der Verbindungslinie. + point = body_a.position + normal * (radius_a - 0.5 * penetration) + return Contact(body_a, body_b, normal, penetration, point) + + +def collide_circle_box(circle_body: Body, box_body: Body) -> Contact | None: + """Kreis (Körper A) gegen achsenparalleles Rechteck (Körper B). + + Idee: Den Kreismittelpunkt komponentenweise auf das Rechteck klemmen. Das liefert den + Punkt des Rechtecks, der dem Kreismittelpunkt am nächsten liegt. Ist er weniger als + einen Radius entfernt, berühren sich die Körper. + + Sonderfall: Liegt der Mittelpunkt *im* Rechteck, ist der geklemmte Punkt der + Mittelpunkt selbst und liefert keine Richtung. Dann wird der Kreis über die nächste + Kante hinausgeschoben (Achse mit der kleinsten Eindringtiefe). + """ + radius = circle_body.shape.radius + half_width = box_body.shape.half_width + half_height = box_body.shape.half_height + box_x = box_body.position.x + box_y = box_body.position.y + # Kreismittelpunkt relativ zum Rechteckmittelpunkt. + dx = circle_body.position.x - box_x + dy = circle_body.position.y - box_y + closest_x = min(max(dx, -half_width), half_width) + closest_y = min(max(dy, -half_height), half_height) + + if closest_x != dx or closest_y != dy: + # Mittelpunkt außerhalb: Abstand zum nächsten Punkt des Rechtecks. + offset_x = dx - closest_x + offset_y = dy - closest_y + distance_sq = offset_x * offset_x + offset_y * offset_y + if distance_sq > radius * radius: + return None + distance = math.sqrt(distance_sq) # > 0, weil der Mittelpunkt außerhalb liegt + # (offset) zeigt vom Rechteck zum Kreis; die Normale soll von A (Kreis) zu B zeigen. + normal = Vector2(-offset_x / distance, -offset_y / distance) + penetration = radius - distance + point = Vector2(box_x + closest_x, box_y + closest_y) + return Contact(circle_body, box_body, normal, penetration, point) + + # Mittelpunkt innerhalb (oder genau auf dem Rand): über die nächste Kante hinausdrücken. + gap_x = half_width - abs(dx) + gap_y = half_height - abs(dy) + if gap_x < gap_y: + side = 1.0 if dx >= 0.0 else -1.0 + # Der Kreis muss in Richtung "side" hinaus, er bewegt sich entlang −n → n = −side. + normal = Vector2(-side, 0.0) + penetration = radius + gap_x + point = Vector2(box_x + side * half_width, circle_body.position.y) + else: + side = 1.0 if dy >= 0.0 else -1.0 + normal = Vector2(0.0, -side) + penetration = radius + gap_y + point = Vector2(circle_body.position.x, box_y + side * half_height) + return Contact(circle_body, box_body, normal, penetration, point) + + +def collide_boxes(body_a: Body, body_b: Body) -> Contact | None: + """Rechteck gegen Rechteck (beide achsenparallel). + + Zwei achsenparallele Rechtecke überlappen genau dann, wenn sich ihre Projektionen auf + beide Achsen überlappen (Trennachsensatz). Getrennt wird entlang der Achse mit der + kleineren Überlappung – das ist der kürzeste Weg aus der Durchdringung. Bei Gleichstand + gewinnt die y-Achse, weil aufeinanderliegende Körper der häufigste Fall sind. + """ + shape_a = body_a.shape + shape_b = body_b.shape + dx = body_b.position.x - body_a.position.x + dy = body_b.position.y - body_a.position.y + overlap_x = shape_a.half_width + shape_b.half_width - abs(dx) + if overlap_x < 0.0: + return None + overlap_y = shape_a.half_height + shape_b.half_height - abs(dy) + if overlap_y < 0.0: + return None + + if overlap_x < overlap_y: + normal = Vector2(1.0 if dx >= 0.0 else -1.0, 0.0) + penetration = overlap_x + else: + normal = Vector2(0.0, 1.0 if dy >= 0.0 else -1.0) + penetration = overlap_y + + # Kontaktpunkt: Mittelpunkt des Überlappungsrechtecks. Da Rechtecke sich nie drehen, + # beeinflusst er nur die Debug-Anzeige, nicht die Physik. + a_x = body_a.position.x + a_y = body_a.position.y + b_x = body_b.position.x + b_y = body_b.position.y + left = max(a_x - shape_a.half_width, b_x - shape_b.half_width) + right = min(a_x + shape_a.half_width, b_x + shape_b.half_width) + top = max(a_y - shape_a.half_height, b_y - shape_b.half_height) + bottom = min(a_y + shape_a.half_height, b_y + shape_b.half_height) + point = Vector2(0.5 * (left + right), 0.5 * (top + bottom)) + return Contact(body_a, body_b, normal, penetration, point) + + +def collide_circle_half_plane(circle_body: Body, wall_body: Body) -> Contact | None: + """Kreis gegen Wand (Halbebene). + + Der Kreis berührt die Wand, wenn der Abstand seines Mittelpunkts zur Wandlinie kleiner + als der Radius ist. Liegt der Mittelpunkt sogar hinter der Wand, ist der Abstand + negativ und die Eindringtiefe entsprechend groß – der Kreis wird trotzdem korrekt in + den freien Raum zurückgeschoben. + """ + wall = wall_body.shape + center = circle_body.position + distance = wall.distance(center) + penetration = circle_body.shape.radius - distance + if penetration < 0.0: + return None + # Die Wandnormale zeigt in den freien Raum, also zum Kreis; A→B ist die Gegenrichtung. + normal = -wall.normal + point = center - wall.normal * distance # Lotfußpunkt auf der Wandlinie + return Contact(circle_body, wall_body, normal, penetration, point) + + +def collide_box_half_plane(box_body: Body, wall_body: Body) -> Contact | None: + """Rechteck gegen Wand (Halbebene). + + Die "Reichweite" eines achsenparallelen Rechtecks entlang der Normalen n ist + ``|n.x|·hw + |n.y|·hh`` (Projektion der Halbdiagonale). Ist der Mittelpunkt näher an der + Wand als diese Reichweite, ragt das Rechteck in die Wand. + """ + wall = wall_body.shape + shape = box_body.shape + center = box_body.position + reach = abs(wall.normal.x) * shape.half_width + abs(wall.normal.y) * shape.half_height + distance = wall.distance(center) + penetration = reach - distance + if penetration < 0.0: + return None + normal = -wall.normal + point = center - wall.normal * distance + return Contact(box_body, wall_body, normal, penetration, point) diff --git a/src/sandbox/physics/shapes.py b/src/sandbox/physics/shapes.py new file mode 100644 index 0000000..4c46332 --- /dev/null +++ b/src/sandbox/physics/shapes.py @@ -0,0 +1,139 @@ +"""Geometrische Formen der Körper: Kreis, achsenparalleles Rechteck und Halbebene. + +Formen beschreiben nur die Geometrie relativ zum Schwerpunkt. Position, Geschwindigkeit +und Masse gehören zum :class:`~sandbox.physics.body.Body`. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import TypeAlias + +from sandbox.config import NUMERIC_EPSILON +from sandbox.physics.vector import Vector2 + + +@dataclass(frozen=True, slots=True) +class Circle: + """Homogene Kreisscheibe. Kreise können sich drehen (z. B. rollen). + + Attributes: + radius: Radius in px (> 0). + """ + + radius: float + + def __post_init__(self) -> None: + if not self.radius > 0.0: + raise ValueError(f"Radius muss positiv sein, ist aber {self.radius}") + + def area(self) -> float: + """Fläche ``π·r²`` – bestimmt zusammen mit der Dichte die Masse.""" + return math.pi * self.radius * self.radius + + def moment_of_inertia(self, mass: float) -> float: + """Trägheitsmoment einer homogenen Scheibe um ihren Mittelpunkt: ``I = ½·m·r²``. + + Herleitung: Die Scheibe besteht aus dünnen Ringen mit Radius s und Masse + ``dm = m/(π·r²) · 2π·s·ds``. Jeder Ring trägt ``s²·dm`` bei, also + ``I = (2m/r²)·∫₀ʳ s³ ds = (2m/r²)·r⁴/4 = ½·m·r²``. + """ + return 0.5 * mass * self.radius * self.radius + + def half_extents(self) -> tuple[float, float]: + """Halbe Breite und Höhe der umschließenden achsenparallelen Box.""" + return (self.radius, self.radius) + + def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool: + """Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Kreis liegt.""" + reach = self.radius + tolerance + return offset.length_squared() <= reach * reach + + +@dataclass(frozen=True, slots=True) +class Box: + """Achsenparalleles Rechteck (AABB). + + Rechtecke bleiben per Definition achsenparallel. Deshalb liefert + :meth:`moment_of_inertia` "unendlich": Das inverse Trägheitsmoment ist dann 0, und kein + Drehimpuls kann das Rechteck je drehen. + + Attributes: + half_width: Halbe Breite in px (> 0). + half_height: Halbe Höhe in px (> 0). + """ + + half_width: float + half_height: float + + def __post_init__(self) -> None: + if not (self.half_width > 0.0 and self.half_height > 0.0): + raise ValueError( + f"Halbe Kantenlängen müssen positiv sein: {self.half_width}, {self.half_height}" + ) + + def area(self) -> float: + """Fläche ``(2·hw)·(2·hh)``.""" + return 4.0 * self.half_width * self.half_height + + def moment_of_inertia(self, mass: float) -> float: + """Unendlich, damit sich das Rechteck nie dreht (siehe Klassenbeschreibung). + + ``mass`` wird nur für eine einheitliche Schnittstelle mit :class:`Circle` angenommen. + """ + del mass + return math.inf + + def half_extents(self) -> tuple[float, float]: + """Halbe Breite und Höhe.""" + return (self.half_width, self.half_height) + + def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool: + """Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Rechteck liegt.""" + return ( + abs(offset.x) <= self.half_width + tolerance + and abs(offset.y) <= self.half_height + tolerance + ) + + +@dataclass(frozen=True, slots=True) +class HalfPlane: + """Unendliche Halbebene – modelliert Boden, Decke und Seitenwände. + + Die Ebene ist durch ``normal·p = offset`` gegeben. Die Normale zeigt in den freien + Raum; alles mit ``normal·p < offset`` ist "in der Wand". Weil eine Halbebene unendlich + dick ist, kann kein Körper durch sie hindurchtunneln: Selbst ein Körper, der in einem + Schritt komplett hinter die Wandlinie springt, wird als (tiefe) Durchdringung erkannt + und zurückgeschoben. + + Attributes: + normal: Einheitsnormale, zeigt in den freien Raum. + offset: Abstand der Ebene vom Ursprung entlang der Normalen. + """ + + normal: Vector2 + offset: float + + def __post_init__(self) -> None: + if abs(self.normal.length() - 1.0) > NUMERIC_EPSILON: + raise ValueError(f"Die Normale muss Länge 1 haben: {self.normal}") + + def distance(self, point: Vector2) -> float: + """Vorzeichenbehafteter Abstand eines Punktes (positiv = im freien Raum).""" + return self.normal.dot(point) - self.offset + + def half_extents(self) -> tuple[float, float]: + """Eine Halbebene ist unbegrenzt.""" + return (math.inf, math.inf) + + def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool: + """Prüft, ob ein Punkt in der Wand (hinter der Ebene) liegt. + + Wandkörper liegen im Ursprung, deshalb ist ``offset`` hier der Punkt selbst. + """ + return self.distance(offset) <= tolerance + + +#: Alle unterstützten Formen. +Shape: TypeAlias = Circle | Box | HalfPlane diff --git a/src/sandbox/physics/sleep.py b/src/sandbox/physics/sleep.py new file mode 100644 index 0000000..e244ef7 --- /dev/null +++ b/src/sandbox/physics/sleep.py @@ -0,0 +1,126 @@ +"""Schlafmodus: ruhende Körper einfrieren, bis etwas sie anstößt. + +Warum: Ein Solver mit endlich vielen Iterationen lässt immer winzige Restbewegungen übrig. +Ein Stapel "atmet" dadurch sichtbar oder kriecht. Friert man Körper ein, die eine Weile +praktisch stillstehen, liegen sie wirklich ruhig – und sparen Rechenzeit, weil sie weder +integriert noch aufgelöst werden müssen. + +Warum gruppenweise ("Inseln")? Würde jeder Körper einzeln einschlafen, könnte ein +schlafender Körper unter einem wachen liegen: Der wache weckt ihn, beide ruhen, der eine +schläft früher ein, der andere weckt ihn wieder – endloses Hin und Her. Deshalb werden +Körper, die sich (direkt oder über andere) berühren, zu einer Insel zusammengefasst, und +eine Insel schläft nur als Ganzes ein – und wacht auch nur als Ganzes wieder auf. Wände +verbinden keine Inseln, sonst wäre alles auf dem Boden eine einzige Insel. +""" + +from __future__ import annotations + +from sandbox.config import PhysicsConfig +from sandbox.physics.body import Body +from sandbox.physics.collision import Contact + + +class UnionFind: + """Disjunkte Mengen ("Union-Find") zum Bilden von Kontakt-Inseln. + + Jede Menge wird durch einen Repräsentanten identifiziert. :meth:`union` verschmilzt zwei + Mengen, :meth:`find` liefert den Repräsentanten – dank Pfadverkürzung in nahezu + konstanter Zeit. + """ + + def __init__(self, keys: list[int]) -> None: + self._parent = {key: key for key in keys} + + def find(self, key: int) -> int: + """Repräsentant der Menge, zu der ``key`` gehört.""" + root = key + while self._parent[root] != root: + root = self._parent[root] + # Pfadverkürzung: alle besuchten Elemente direkt an die Wurzel hängen. + while self._parent[key] != root: + next_key = self._parent[key] + self._parent[key] = root + key = next_key + return root + + def union(self, first: int, second: int) -> None: + """Verschmilzt die Mengen von ``first`` und ``second``.""" + root_first = self.find(first) + root_second = self.find(second) + if root_first != root_second: + self._parent[root_second] = root_first + + +def wake_touched_bodies(contacts: list[Contact]) -> list[Body]: + """Weckt schlafende Körper, die von einem wachen dynamischen Körper berührt werden. + + Mit dem berührten Körper wacht seine ganze Schlaf-Insel auf (siehe + :meth:`Body.wake `). Die geweckten Körper gehören ab + sofort zur Insel des wachen Körpers und schlafen erst wieder ein, wenn die ganze Insel + zur Ruhe gekommen ist. + + Returns: + Alle in diesem Aufruf geweckten Körper. + """ + woken: list[Body] = [] + for contact in contacts: + body_a = contact.body_a + body_b = contact.body_b + if body_a.sleeping == body_b.sleeping: + continue # beide wach, beide schlafend – oder schon von einer Insel geweckt + sleeper, other = (body_a, body_b) if body_a.sleeping else (body_b, body_a) + if other.is_static: + continue # Wände wecken niemanden – sonst schliefe nichts am Boden je ein. + island = sleeper.sleep_island + sleeper.wake() + woken.extend(island if island is not None else (sleeper,)) + return woken + + +def find_islands(bodies: list[Body], contacts: list[Contact]) -> list[list[Body]]: + """Gruppiert wache dynamische Körper nach Berührung zu Inseln. + + Args: + bodies: Die wachen dynamischen Körper. + contacts: Die Kontakte des aktuellen Schritts. + + Returns: + Inseln in der Reihenfolge ihres ersten Körpers in ``bodies``. + """ + union_find = UnionFind([body.id for body in bodies]) + for contact in contacts: + if contact.body_a.is_static or contact.body_b.is_static: + continue + union_find.union(contact.body_a.id, contact.body_b.id) + + islands: dict[int, list[Body]] = {} + for body in bodies: + islands.setdefault(union_find.find(body.id), []).append(body) + return list(islands.values()) + + +def update_sleep( + bodies: list[Body], contacts: list[Contact], dt: float, config: PhysicsConfig +) -> None: + """Aktualisiert die Ruhezeiten und schickt vollständig ruhende Inseln schlafen. + + Args: + bodies: Die wachen dynamischen Körper dieses Schritts. + contacts: Die Kontakte dieses Schritts. + dt: Zeitschritt in s. + config: Schwellwerte für Ruhe und Einschlafzeit. + """ + linear_limit_sq = config.sleep_linear_speed * config.sleep_linear_speed + angular_limit = config.sleep_angular_speed + for body in bodies: + resting = ( + not body.held + and body.velocity.length_squared() <= linear_limit_sq + and abs(body.angular_velocity) <= angular_limit + ) + body.sleep_time = body.sleep_time + dt if resting else 0.0 + + for island in find_islands(bodies, contacts): + if all(body.sleep_time >= config.time_to_sleep for body in island): + for body in island: + body.put_to_sleep(island) # alle teilen sich dieselbe Liste diff --git a/src/sandbox/physics/solver.py b/src/sandbox/physics/solver.py new file mode 100644 index 0000000..9625005 --- /dev/null +++ b/src/sandbox/physics/solver.py @@ -0,0 +1,499 @@ +"""Impulsbasierte Kollisionsauflösung ("Sequential Impulses") und Positionskorrektur. + +Bedingungen pro Kontakt +----------------------- +1. **Nichtdurchdringung** entlang der Normalen n: Am Kontaktpunkt dürfen sich die Körper + nicht weiter annähern, ``v_n ≥ 0``. Bei einem echten Aufprall gilt zusätzlich Newtons + Stoßgesetz ``v_n' = −e·v_n`` (e = Restitution). +2. **Reibung** entlang der Tangente t: Das Gleiten ``v_t`` soll verschwinden, der + Reibungsimpuls ist aber nach Coulomb auf ``|P_t| ≤ μ·P_n`` begrenzt. Reicht das nicht, + rutschen die Körper weiter (Gleitreibung). + +Herleitung des Kollisionsimpulses +--------------------------------- +Relativgeschwindigkeit des Kontaktpunkts (B relativ zu A) mit den Hebelarmen r_A, r_B vom +Schwerpunkt zum Kontaktpunkt und ``ω × r = ω·(−r.y, r.x)``:: + + v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), v_n = v_rel · n + +Ein Impuls ``P = λ·n`` wirkt auf B positiv und auf A negativ (actio = reactio):: + + v_A' = v_A − λ·n/m_A ω_A' = ω_A − (r_A × λn)/I_A + v_B' = v_B + λ·n/m_B ω_B' = ω_B + (r_B × λn)/I_B + +Eingesetzt ändert sich die Normalgeschwindigkeit linear in λ:: + + v_n' = v_n + λ·K, K = 1/m_A + 1/m_B + (r_A × n)²/I_A + (r_B × n)²/I_B + +Aus ``v_n' = −e·v_n`` folgt ``λ = −(1 + e)·v_n / K``. Der Kehrwert 1/K ist die *effektive +Masse* des Kontakts: die träge Masse, die der Stoß entlang n "spürt". Für die Reibung gilt +dasselbe mit t statt n und dem Ziel ``v_t' = 0``. + +In dieser Engine verschwinden die Drehterme von K für die Normale: Bei Kreisen geht die +Normale durch den Mittelpunkt (r ∥ n, also r × n = 0), und Rechtecke drehen sich nie +(1/I = 0). Normalimpulse erzeugen daher kein Drehmoment – nur die Reibung bringt Kreise +zum Rollen. + +Rollwiderstand +-------------- +Reines Rollen verbraucht keine Reibungsenergie: Ohne weitere Kraft rollte ein Kreis ewig, +und ein Rechteck auf einem Kreis führe darauf wie auf einer Rolle davon. Real verformen +sich Kreis und Unterlage ein wenig, was ein bremsendes Moment ``M ≤ μ_r·N·r`` erzeugt +(μ_r = Rollwiderstandsbeiwert, N = Normalkraft, r = Radius). Pro Schritt entspricht das +einem Drehimpuls ``≤ μ_r·P_n·r``. Umgesetzt ist es wie die Reibung als begrenzter Impuls, +nur auf die relative Winkelgeschwindigkeit ``ω_B − ω_A`` mit der effektiven Masse +``1/(1/I_A + 1/I_B)``. Weil die Grenze mit der Auflagekraft wächst, kommen schwer +belastete "Rollen" schnell zur Ruhe, während ein frei rollender Ball weit rollt. + +Warum iterativ? +--------------- +Liegt ein Körper auf mehreren anderen (Stapel), beeinflussen sich die Kontakte gegenseitig. +Statt ein Gleichungssystem zu lösen, werden die Kontakte reihum mehrfach aufgelöst +(Gauß-Seidel-Verfahren). Geklemmt wird dabei der über alle Durchläufe *akkumulierte* +Impuls (≥ 0), nicht der einzelne Teilimpuls: Ein späterer Durchlauf darf einen zu großen +früheren Impuls wieder zurücknehmen. So konvergiert das Verfahren gegen die physikalische +Lösung, in der Kontakte nur drücken, aber nie ziehen. + +Warm-Starting: Die akkumulierten Impulse des vorigen Schritts sind ein sehr guter +Startwert, weil sich ruhende Kontakte kaum ändern. Ein Stapel steht so schon nach wenigen +Durchläufen stabil, statt jeden Schritt bei 0 beginnen zu müssen. + +Leistung: Die inneren Schleifen laufen tausendfach pro Schritt. Deshalb arbeiten sie auf +lokalen Float-Listen statt auf Vektorobjekten; die Formeln sind dieselben wie oben, +komponentenweise ausgeschrieben. +""" + +from __future__ import annotations + +import math +from typing import TypeAlias + +from sandbox.config import PhysicsConfig +from sandbox.physics.body import Body +from sandbox.physics.collision import Contact +from sandbox.physics.vector import Vector2 + +#: Gespeicherte Impulse eines Kontakts für das Warm-Starting: +#: (Normalimpuls, Reibungsimpuls, Rollwiderstandsimpuls, Normale x, Normale y). +_CachedImpulses: TypeAlias = tuple[float, float, float, float, float] + + +class _Constraint: + """Vorberechnete Solver-Daten eines Kontakts (nur Floats, für schnelle Iterationen).""" + + __slots__ = ( + "contact", + "friction", + "index_a", + "index_b", + "inv_inertia_a", + "inv_inertia_b", + "inv_mass_a", + "inv_mass_b", + "normal_impulse", + "normal_mass", + "normal_x", + "normal_y", + "offset_a_x", + "offset_a_y", + "offset_b_x", + "offset_b_y", + "penetration", + "rolling_impulse", + "rolling_limit", + "rolling_mass", + "tangent_impulse", + "tangent_mass", + "tangent_x", + "tangent_y", + "velocity_bias", + ) + + contact: Contact + friction: float + index_a: int + index_b: int + inv_inertia_a: float + inv_inertia_b: float + inv_mass_a: float + inv_mass_b: float + normal_impulse: float + normal_mass: float + normal_x: float + normal_y: float + offset_a_x: float + offset_a_y: float + offset_b_x: float + offset_b_y: float + penetration: float + rolling_impulse: float + rolling_limit: float + rolling_mass: float + tangent_impulse: float + tangent_mass: float + tangent_x: float + tangent_y: float + velocity_bias: float + + +class ContactSolver: + """Löst Kontakte über Impulse auf und korrigiert verbleibende Durchdringungen. + + Ablauf pro Simulationsschritt (gesteuert von :class:`~sandbox.physics.world.World`): + + 1. :meth:`solve_velocities` – Geschwindigkeiten so ändern, dass kein Kontakt sich weiter + schließt (mit Restitution und Reibung). + 2. Die Welt integriert die Positionen mit den neuen Geschwindigkeiten. + 3. :meth:`correct_positions` – verbliebene Überlappungen direkt über die Positionen + abbauen, ohne Geschwindigkeiten zu ändern. + """ + + def __init__(self, config: PhysicsConfig) -> None: + self._config = config + self._constraints: list[_Constraint] = [] + self._bodies: list[Body] = [] + self._velocity_x: list[float] = [] + self._velocity_y: list[float] = [] + self._angular_velocity: list[float] = [] + self._start_x: list[float] = [] + self._start_y: list[float] = [] + # Akkumulierte Impulse des letzten Schritts je Körperpaar (für das Warm-Starting). + self._impulse_cache: dict[tuple[int, int], _CachedImpulses] = {} + + def reset(self) -> None: + """Vergisst alle gespeicherten Impulse (z. B. nachdem die Welt geleert wurde).""" + self._constraints = [] + self._bodies = [] + self._impulse_cache = {} + + # ------------------------------------------------------------------ Geschwindigkeiten + + def solve_velocities(self, contacts: list[Contact]) -> None: + """Berechnet die Kontaktimpulse und schreibt die neuen Geschwindigkeiten zurück. + + Nach dem Aufruf enthalten die Kontakte die aufgebrachten Impulse + (``normal_impulse``, ``tangent_impulse``). + """ + self._prepare(contacts) + if self._config.warm_starting: + self._warm_start() + for _ in range(self._config.velocity_iterations): + self._solve_velocity_iteration() + self._store_velocities() + + def _prepare(self, contacts: list[Contact]) -> None: + """Legt die Float-Arbeitskopien an und berechnet die effektiven Massen.""" + bodies: list[Body] = [] + index_of: dict[int, int] = {} + index_pairs: list[tuple[int, int]] = [] + for contact in contacts: + indices = [] + for body in (contact.body_a, contact.body_b): + index = index_of.get(body.id) + if index is None: + index = len(bodies) + index_of[body.id] = index + bodies.append(body) + indices.append(index) + index_pairs.append((indices[0], indices[1])) + + self._bodies = bodies + self._velocity_x = [body.velocity.x for body in bodies] + self._velocity_y = [body.velocity.y for body in bodies] + self._angular_velocity = [body.angular_velocity for body in bodies] + # Positionen bei der Kollisionserkennung – Bezug für die spätere Positionskorrektur. + self._start_x = [body.position.x for body in bodies] + self._start_y = [body.position.y for body in bodies] + self._constraints = [ + self._make_constraint(contact, index_a, index_b) + for contact, (index_a, index_b) in zip(contacts, index_pairs, strict=True) + ] + + def _make_constraint(self, contact: Contact, index_a: int, index_b: int) -> _Constraint: + """Berechnet alle Größen eines Kontakts, die sich während der Iterationen nicht ändern. + + Läuft für jeden Kontakt in jedem Schritt und rechnet deshalb – wie die Iterationen – + komponentenweise mit Floats statt mit temporären Vektorobjekten. + """ + body_a = contact.body_a + body_b = contact.body_b + normal_x = contact.normal.x + normal_y = contact.normal.y + # Tangente = Normale um 90° gedreht (−n.y, n.x). Ihr Vorzeichen ist beliebig, weil + # der Reibungsimpuls symmetrisch auf [−μ·P_n, +μ·P_n] geklemmt wird. + tangent_x = -normal_y + tangent_y = normal_x + # Hebelarme r_A, r_B: vom jeweiligen Schwerpunkt zum Kontaktpunkt. + point = contact.point + offset_a_x = point.x - body_a.position.x + offset_a_y = point.y - body_a.position.y + offset_b_x = point.x - body_b.position.x + offset_b_y = point.y - body_b.position.y + + constraint = _Constraint() + constraint.contact = contact + constraint.index_a = index_a + constraint.index_b = index_b + constraint.normal_x = normal_x + constraint.normal_y = normal_y + constraint.tangent_x = tangent_x + constraint.tangent_y = tangent_y + constraint.offset_a_x = offset_a_x + constraint.offset_a_y = offset_a_y + constraint.offset_b_x = offset_b_x + constraint.offset_b_y = offset_b_y + constraint.inv_mass_a = body_a.inv_mass + constraint.inv_mass_b = body_b.inv_mass + constraint.inv_inertia_a = body_a.inv_inertia + constraint.inv_inertia_b = body_b.inv_inertia + + # Effektive Massen 1/K (siehe Moduldokumentation). Für die Normale entfallen die + # Drehterme, weil in dieser Engine stets r × n = 0 oder 1/I = 0 gilt. + inv_mass_sum = body_a.inv_mass + body_b.inv_mass + constraint.normal_mass = 1.0 / inv_mass_sum if inv_mass_sum > 0.0 else 0.0 + arm_a = offset_a_x * tangent_y - offset_a_y * tangent_x # r_A × t + arm_b = offset_b_x * tangent_y - offset_b_y * tangent_x # r_B × t + k_tangent = ( + inv_mass_sum + body_a.inv_inertia * arm_a * arm_a + body_b.inv_inertia * arm_b * arm_b + ) + constraint.tangent_mass = 1.0 / k_tangent if k_tangent > 0.0 else 0.0 + + # Rollwiderstand (siehe Moduldokumentation): nur, wenn sich mindestens ein Körper + # drehen kann. Als Radius dient der längere Hebelarm eines drehbaren Körpers. + inv_inertia_sum = body_a.inv_inertia + body_b.inv_inertia + if inv_inertia_sum > 0.0: + lever = max( + math.hypot(offset_a_x, offset_a_y) if body_a.can_rotate else 0.0, + math.hypot(offset_b_x, offset_b_y) if body_b.can_rotate else 0.0, + ) + constraint.rolling_mass = 1.0 / inv_inertia_sum + constraint.rolling_limit = self._config.rolling_resistance * lever + else: + constraint.rolling_mass = 0.0 + constraint.rolling_limit = 0.0 + + # Materialwerte mischen: Die Reibung als geometrisches Mittel (ein glatter Partner + # macht den Kontakt glatt), die Restitution als Maximum (ein Gummiball springt auch + # von einem Holzklotz ab). + constraint.friction = math.sqrt(body_a.material.friction * body_b.material.friction) + restitution = max(body_a.material.restitution, body_b.material.restitution) + + # Abprallen nach Newton: v_n' = −e·v_n. Die Aufprallgeschwindigkeit v_n stammt vom + # Beginn des Schritts – vor Schwerkraft und Warm-Start. Mit der Geschwindigkeit nach + # der Schwerkraft prallte ein Ball mit e·(v + g·dt) statt e·v ab und gewänne bei + # e = 1 mit jedem Aufprall Höhe (Energie aus dem Nichts). + velocity_a = body_a.previous_velocity + velocity_b = body_b.previous_velocity + normal_speed = (velocity_b.x - velocity_a.x) * normal_x + ( + velocity_b.y - velocity_a.y + ) * normal_y + # Kein Abprallen, (1) unterhalb der Schwelle: Aufliegende Körper würden sonst durch + # winzige Dauer-Hüpfer zittern; (2) wenn ein Körper gehalten wird: Die Hand fängt + # den Stoß ab. Sonst prallte ein gegen ein Hindernis gedrückter Körper in jedem + # Schritt ab und würde gleich wieder hingezogen – er zitterte. + bounces = normal_speed < -self._config.restitution_threshold + if bounces and not (body_a.held or body_b.held): + constraint.velocity_bias = -restitution * normal_speed + else: + constraint.velocity_bias = 0.0 + + constraint.normal_impulse = 0.0 + constraint.tangent_impulse = 0.0 + constraint.rolling_impulse = 0.0 + constraint.penetration = contact.penetration + return constraint + + def _warm_start(self) -> None: + """Wendet die Impulse des letzten Schritts erneut an (bessere Startwerte).""" + cache = self._impulse_cache + min_alignment = self._config.warm_start_min_alignment + velocity_x = self._velocity_x + velocity_y = self._velocity_y + angular = self._angular_velocity + for c in self._constraints: + cached = cache.get(c.contact.key) + if cached is None: + continue + normal_impulse, tangent_impulse, rolling_impulse, old_normal_x, old_normal_y = cached + if old_normal_x * c.normal_x + old_normal_y * c.normal_y < min_alignment: + continue # Normale hat sich zu stark gedreht, der alte Impuls passt nicht. + c.normal_impulse = normal_impulse + c.tangent_impulse = tangent_impulse + c.rolling_impulse = rolling_impulse + impulse_x = normal_impulse * c.normal_x + tangent_impulse * c.tangent_x + impulse_y = normal_impulse * c.normal_y + tangent_impulse * c.tangent_y + a = c.index_a + b = c.index_b + velocity_x[a] -= impulse_x * c.inv_mass_a + velocity_y[a] -= impulse_y * c.inv_mass_a + angular[a] -= c.inv_inertia_a * ( + c.offset_a_x * impulse_y - c.offset_a_y * impulse_x + rolling_impulse + ) + velocity_x[b] += impulse_x * c.inv_mass_b + velocity_y[b] += impulse_y * c.inv_mass_b + angular[b] += c.inv_inertia_b * ( + c.offset_b_x * impulse_y - c.offset_b_y * impulse_x + rolling_impulse + ) + + def _solve_velocity_iteration(self) -> None: + """Ein Gauß-Seidel-Durchlauf über alle Kontakte. + + Reihenfolge je Kontakt: Normale, dann Reibung, dann Rollwiderstand. Reibung und + Rollwiderstand sind durch den Normalimpuls begrenzt; werden sie danach gelöst, gilt + ihre Grenze nach jedem Durchlauf exakt für den aktuellen Normalimpuls. (Andersherum + könnte der Normalimpuls hinterher noch sinken und die Reibung über der + Coulomb-Grenze zurücklassen.) + """ + velocity_x = self._velocity_x + velocity_y = self._velocity_y + angular = self._angular_velocity + for c in self._constraints: + a = c.index_a + b = c.index_b + + # --- Normale: Drehterme entfallen hier (siehe Moduldokumentation). + normal_speed = (velocity_x[b] - velocity_x[a]) * c.normal_x + ( + velocity_y[b] - velocity_y[a] + ) * c.normal_y + # λ = (Ziel − v_n)/K; Ziel ist die Abprallgeschwindigkeit (oder 0). + old_impulse = c.normal_impulse + new_impulse = old_impulse + (c.velocity_bias - normal_speed) * c.normal_mass + if new_impulse < 0.0: + new_impulse = 0.0 # Kontakte können nur drücken, nie ziehen. + c.normal_impulse = new_impulse + delta = new_impulse - old_impulse + impulse_x = delta * c.normal_x + impulse_y = delta * c.normal_y + velocity_x[a] -= impulse_x * c.inv_mass_a + velocity_y[a] -= impulse_y * c.inv_mass_a + velocity_x[b] += impulse_x * c.inv_mass_b + velocity_y[b] += impulse_y * c.inv_mass_b + + # --- Reibung: v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), ω × r = (−ω·r.y, ω·r.x) + rel_x = (velocity_x[b] - angular[b] * c.offset_b_y) - ( + velocity_x[a] - angular[a] * c.offset_a_y + ) + rel_y = (velocity_y[b] + angular[b] * c.offset_b_x) - ( + velocity_y[a] + angular[a] * c.offset_a_x + ) + tangent_speed = rel_x * c.tangent_x + rel_y * c.tangent_y + # λ so, dass das Gleiten verschwindet; danach Coulomb-Grenze auf den Gesamtimpuls. + max_friction = c.friction * c.normal_impulse + old_impulse = c.tangent_impulse + new_impulse = old_impulse - tangent_speed * c.tangent_mass + if new_impulse > max_friction: + new_impulse = max_friction + elif new_impulse < -max_friction: + new_impulse = -max_friction + c.tangent_impulse = new_impulse + delta = new_impulse - old_impulse + impulse_x = delta * c.tangent_x + impulse_y = delta * c.tangent_y + velocity_x[a] -= impulse_x * c.inv_mass_a + velocity_y[a] -= impulse_y * c.inv_mass_a + angular[a] -= c.inv_inertia_a * (c.offset_a_x * impulse_y - c.offset_a_y * impulse_x) + velocity_x[b] += impulse_x * c.inv_mass_b + velocity_y[b] += impulse_y * c.inv_mass_b + angular[b] += c.inv_inertia_b * (c.offset_b_x * impulse_y - c.offset_b_y * impulse_x) + + # --- Rollwiderstand: bremst die relative Drehung, höchstens μ_r·P_n·r. + if c.rolling_mass > 0.0: + max_rolling = c.rolling_limit * c.normal_impulse + old_impulse = c.rolling_impulse + new_impulse = old_impulse - (angular[b] - angular[a]) * c.rolling_mass + if new_impulse > max_rolling: + new_impulse = max_rolling + elif new_impulse < -max_rolling: + new_impulse = -max_rolling + c.rolling_impulse = new_impulse + delta = new_impulse - old_impulse + angular[a] -= c.inv_inertia_a * delta + angular[b] += c.inv_inertia_b * delta + + def _store_velocities(self) -> None: + """Überträgt die Ergebnisse auf Körper und Kontakte und merkt sich die Impulse.""" + for index, body in enumerate(self._bodies): + if body.is_static: + continue + body.velocity = Vector2(self._velocity_x[index], self._velocity_y[index]) + body.angular_velocity = self._angular_velocity[index] + + cache: dict[tuple[int, int], _CachedImpulses] = {} + for c in self._constraints: + contact = c.contact + contact.normal_impulse = c.normal_impulse + contact.tangent_impulse = c.tangent_impulse + cache[contact.key] = ( + c.normal_impulse, + c.tangent_impulse, + c.rolling_impulse, + c.normal_x, + c.normal_y, + ) + self._impulse_cache = cache + + # ------------------------------------------------------------------ Positionen + + def correct_positions(self) -> None: + """Baut verbliebene Durchdringungen der zuletzt gelösten Kontakte ab. + + Warum überhaupt? Impulse korrigieren nur Geschwindigkeiten. Durch den diskreten + Zeitschritt überlappen Körper trotzdem ein wenig (ein fallender Körper "landet" + meist schon etwas in der Unterlage), und ohne Korrektur würden Stapel langsam + ineinander einsinken. + + Warum über Positionen statt über Zusatzgeschwindigkeit? Eine Geschwindigkeit, die + Körper auseinandertreibt, bleibt nach der Trennung erhalten und schleudert sie + davon – besonders bei großen Überlappungen, etwa wenn ein Objekt mitten in einen + Haufen gesetzt wird. Die direkte Verschiebung ("Projektion") erzeugt dagegen keine + Bewegungsenergie. + + Verschoben wird im Verhältnis der inversen Massen – der leichtere Körper weicht + weiter aus, eine Wand gar nicht. Die kleine Toleranz ``penetration_slop`` bleibt + stehen, damit der Kontakt im nächsten Schritt sicher wieder erkannt wird. + """ + constraints = self._constraints + if not constraints: + return + config = self._config + slop = config.penetration_slop + factor = config.position_correction_factor + max_correction = config.max_position_correction + bodies = self._bodies + position_x = [body.position.x for body in bodies] + position_y = [body.position.y for body in bodies] + start_x = self._start_x + start_y = self._start_y + + for _ in range(config.position_iterations): + for c in constraints: + a = c.index_a + b = c.index_b + # Aktuelle Tiefe = Tiefe bei der Erkennung minus die seither erfolgte + # Relativbewegung entlang der Normalen. Für Wände und Rechtecke ist das exakt + # (keine Drehung), für Kreise eine sehr gute Näherung – und viel billiger als + # eine erneute Kollisionserkennung. + moved_x = (position_x[b] - start_x[b]) - (position_x[a] - start_x[a]) + moved_y = (position_y[b] - start_y[b]) - (position_y[a] - start_y[a]) + penetration = c.penetration - (moved_x * c.normal_x + moved_y * c.normal_y) + correction = (penetration - slop) * factor + if correction <= 0.0: + continue + correction = min(correction, max_correction) + # Aufteilen im Verhältnis der inversen Massen (Summe > 0, da nie zwei + # statische Körper einen Kontakt bilden). + share = correction / (c.inv_mass_a + c.inv_mass_b) + shift_a = share * c.inv_mass_a + shift_b = share * c.inv_mass_b + position_x[a] -= c.normal_x * shift_a + position_y[a] -= c.normal_y * shift_a + position_x[b] += c.normal_x * shift_b + position_y[b] += c.normal_y * shift_b + + for index, body in enumerate(bodies): + if body.is_static: + continue + new_x = position_x[index] + new_y = position_y[index] + if new_x != body.position.x or new_y != body.position.y: + body.position = Vector2(new_x, new_y) diff --git a/src/sandbox/physics/timestep.py b/src/sandbox/physics/timestep.py new file mode 100644 index 0000000..6b145a7 --- /dev/null +++ b/src/sandbox/physics/timestep.py @@ -0,0 +1,69 @@ +"""Fester Zeitschritt, entkoppelt von der Bildrate ("Fixed Timestep"). + +Warum: Die Stabilität der Integration hängt vom Zeitschritt ab. Würde die Physik einfach +die (schwankende) Frame-Dauer als dt verwenden, verhielte sich die Simulation auf +schnellen und langsamen Rechnern unterschiedlich, und ein einzelner Ruckler (z. B. 200 ms +beim Verschieben des Fensters) ließe Körper durcheinander hindurchspringen. + +Stattdessen sammelt ein Akkumulator die real vergangene Zeit und gibt sie in festen +Portionen ``dt`` an die Physik weiter. Der Rest bleibt für den nächsten Frame liegen; sein +Anteil an dt (``alpha``) erlaubt es, beim Zeichnen zwischen den letzten beiden +Physikzuständen zu interpolieren, damit die Bewegung trotzdem flüssig aussieht. +""" + +from __future__ import annotations + +import math + +from sandbox.config import NUMERIC_EPSILON + + +class FixedTimestep: + """Akkumulator, der Frame-Zeiten in eine Anzahl fester Physikschritte übersetzt. + + Args: + step: Fester Zeitschritt in s. + max_steps_per_frame: Obergrenze der Schritte pro Frame. Schafft der Rechner die + Physik nicht in Echtzeit, würde der Rückstand sonst immer weiter wachsen + ("Spiral of Death"). Mit Obergrenze läuft die Simulation dann nur langsamer. + """ + + def __init__(self, step: float, max_steps_per_frame: int) -> None: + if not step > 0.0: + raise ValueError(f"Der Zeitschritt muss positiv sein: {step}") + if max_steps_per_frame < 1: + raise ValueError("Es muss mindestens ein Schritt pro Frame erlaubt sein") + self.step = step + self.max_steps_per_frame = max_steps_per_frame + self._accumulator = 0.0 + + @property + def alpha(self) -> float: + """Anteil des angebrochenen Schritts in [0, 1) – Gewicht für die Interpolation.""" + return self._accumulator / self.step + + def advance(self, frame_time: float) -> int: + """Nimmt die Dauer eines Frames entgegen und liefert die Zahl fälliger Schritte. + + Args: + frame_time: Real vergangene Zeit seit dem letzten Aufruf in s. + + Returns: + Anzahl der jetzt auszuführenden Physikschritte. + """ + if frame_time < 0.0: + raise ValueError(f"Die Frame-Zeit darf nicht negativ sein: {frame_time}") + self._accumulator += frame_time + # Das kleine Epsilon fängt Rundungsfehler ab: 2·(1/120) ergibt als Gleitkommazahl + # nicht exakt 1/60, und ohne Toleranz ginge gelegentlich ein Schritt "verloren". + steps = math.floor(self._accumulator / self.step + NUMERIC_EPSILON) + if steps > self.max_steps_per_frame: + steps = self.max_steps_per_frame + self._accumulator = 0.0 # Rückstand verwerfen statt ihn endlos mitzuschleppen. + else: + self._accumulator = max(0.0, self._accumulator - steps * self.step) + return steps + + def reset(self) -> None: + """Verwirft angesammelte Zeit (z. B. beim Pausieren).""" + self._accumulator = 0.0 diff --git a/src/sandbox/physics/vector.py b/src/sandbox/physics/vector.py new file mode 100644 index 0000000..7e1c681 --- /dev/null +++ b/src/sandbox/physics/vector.py @@ -0,0 +1,162 @@ +"""Eigene 2D-Vektorklasse der Physik-Schicht. + +Die Physik nutzt bewusst weder ``pygame.Vector2`` noch externe Mathematik-Bibliotheken, +sondern nur diese Klasse und das Standardmodul :mod:`math`. Dadurch hängt die Physik nicht +von einer Grafikbibliothek ab und lässt sich isoliert testen. +""" + +from __future__ import annotations + +import math +from typing import Final + +from sandbox.config import NUMERIC_EPSILON + +# ``object.__setattr__`` einmal nachschlagen statt bei jedem Konstruktoraufruf: Vektoren +# entstehen tausendfach pro Simulationsschritt, und das normale Setzen ist gesperrt (s. u.). +_set_slot = object.__setattr__ + + +class Vector2: + """Unveränderlicher zweidimensionaler Vektor. + + Jede Operation liefert einen neuen Vektor, kein Operand wird verändert. Vektoren + verhalten sich damit wie Zahlen: Ein Körper kann seine Position herausgeben, ohne dass + jemand sie über die geteilte Referenz versehentlich mitverändert. Zuweisungen wie + ``v.x = 1.0`` lösen deshalb einen :class:`AttributeError` aus. + + Attributes: + x: x-Komponente. + y: y-Komponente (zeigt wie auf dem Bildschirm nach unten). + """ + + __slots__ = ("x", "y") + + x: float + y: float + + def __init__(self, x: float = 0.0, y: float = 0.0) -> None: + _set_slot(self, "x", x) + _set_slot(self, "y", y) + + # ------------------------------------------------------------------ Unveränderlichkeit + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gesetzt werden)") + + def __delattr__(self, name: str) -> None: + raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gelöscht werden)") + + def __reduce__(self) -> tuple[type[Vector2], tuple[float, float]]: + # copy und pickle würden die Slots sonst per setattr befüllen – das ist gesperrt. + return (Vector2, (self.x, self.y)) + + # ------------------------------------------------------------------ Arithmetik + + def __add__(self, other: Vector2) -> Vector2: + return Vector2(self.x + other.x, self.y + other.y) + + def __sub__(self, other: Vector2) -> Vector2: + return Vector2(self.x - other.x, self.y - other.y) + + def __mul__(self, scalar: float) -> Vector2: + return Vector2(self.x * scalar, self.y * scalar) + + def __rmul__(self, scalar: float) -> Vector2: + return Vector2(self.x * scalar, self.y * scalar) + + def __truediv__(self, scalar: float) -> Vector2: + return Vector2(self.x / scalar, self.y / scalar) + + def __neg__(self) -> Vector2: + return Vector2(-self.x, -self.y) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Vector2): + return NotImplemented + return self.x == other.x and self.y == other.y + + def __hash__(self) -> int: + return hash((self.x, self.y)) + + def __repr__(self) -> str: + return f"Vector2({self.x!r}, {self.y!r})" + + # ------------------------------------------------------------------ Vektoroperationen + + def dot(self, other: Vector2) -> float: + """Skalarprodukt ``a·b = a.x·b.x + a.y·b.y``. + + Liefert die Länge der Projektion von ``other`` auf diesen Vektor (mal dessen Länge). + Mit einer Einheitsnormalen n misst ``v.dot(n)`` also, wie schnell sich etwas + entlang n bewegt. + """ + return self.x * other.x + self.y * other.y + + def cross(self, other: Vector2) -> float: + """z-Komponente des Kreuzprodukts ``a × b = a.x·b.y − a.y·b.x``. + + In 2D ist das Kreuzprodukt ein Skalar. Physikalisch ist ``r × F`` das Drehmoment, + das eine Kraft (bzw. ein Impuls) F am Hebelarm r erzeugt. + """ + return self.x * other.y - self.y * other.x + + def length(self) -> float: + """Euklidische Länge (Betrag) des Vektors.""" + return math.hypot(self.x, self.y) + + def length_squared(self) -> float: + """Quadrat der Länge – spart die Wurzel, wenn nur Längen verglichen werden.""" + return self.x * self.x + self.y * self.y + + def normalized(self) -> Vector2: + """Einheitsvektor gleicher Richtung. + + Der Nullvektor hat keine Richtung. Statt eine Division durch 0 (und damit NaN-Werte, + die sich durch die ganze Simulation fressen würden) zu riskieren, wird dann der + Nullvektor zurückgegeben; der Aufrufer entscheidet über eine Ersatzrichtung. + """ + length = math.hypot(self.x, self.y) + if length <= NUMERIC_EPSILON: + return Vector2(0.0, 0.0) + return Vector2(self.x / length, self.y / length) + + def perpendicular(self) -> Vector2: + """Um +90° gedrehter Vektor ``(−y, x)``. + + Nützlich für Tangenten und für ``ω × r``: Die Bahngeschwindigkeit eines Punktes im + Abstand r eines mit ω drehenden Körpers ist ``ω · r.perpendicular()``. + """ + return Vector2(-self.y, self.x) + + def distance_to(self, other: Vector2) -> float: + """Abstand zwischen zwei Punkten.""" + return math.hypot(other.x - self.x, other.y - self.y) + + def clamped(self, max_length: float) -> Vector2: + """Vektor gleicher Richtung, dessen Länge höchstens ``max_length`` beträgt.""" + length_sq = self.x * self.x + self.y * self.y + if length_sq <= max_length * max_length: + return self + scale = max_length / math.sqrt(length_sq) + return Vector2(self.x * scale, self.y * scale) + + def lerp(self, other: Vector2, t: float) -> Vector2: + """Lineare Interpolation: ``t = 0`` liefert ``self``, ``t = 1`` liefert ``other``.""" + return Vector2(self.x + (other.x - self.x) * t, self.y + (other.y - self.y) * t) + + def is_finite(self) -> bool: + """True, wenn keine Komponente NaN oder unendlich ist.""" + return math.isfinite(self.x) and math.isfinite(self.y) + + def is_close(self, other: Vector2, tolerance: float = NUMERIC_EPSILON) -> bool: + """Komponentenweiser Vergleich mit absoluter Toleranz.""" + return abs(self.x - other.x) <= tolerance and abs(self.y - other.y) <= tolerance + + def to_tuple(self) -> tuple[float, float]: + """Komponenten als Tupel ``(x, y)``, z. B. für Zeichenfunktionen.""" + return (self.x, self.y) + + +#: Der Nullvektor. Da Vektoren unveränderlich sind, darf er überall geteilt werden. +ZERO: Final = Vector2(0.0, 0.0) diff --git a/src/sandbox/physics/world.py b/src/sandbox/physics/world.py new file mode 100644 index 0000000..8981c4b --- /dev/null +++ b/src/sandbox/physics/world.py @@ -0,0 +1,280 @@ +"""Die Physikwelt: verwaltet Körper und Wände und führt Simulationsschritte aus.""" + +from __future__ import annotations + +import math + +from sandbox.config import PhysicsConfig +from sandbox.physics.body import Body, Material +from sandbox.physics.broadphase import find_candidate_pairs +from sandbox.physics.collision import Contact, collide +from sandbox.physics.sleep import update_sleep, wake_touched_bodies +from sandbox.physics.solver import ContactSolver +from sandbox.physics.vector import ZERO, Vector2 + + +class World: + """Rechteckige Welt mit Wänden an allen vier Seiten. + + Koordinaten: Ursprung oben links, x nach rechts, y nach unten, Einheit px. Die Welt + kennt keinen Bildschirm – wo sie gezeichnet wird, entscheidet die Anwendung. + + Ein Aufruf von :meth:`step` rückt die Simulation um einen festen Zeitschritt vor + (semi-impliziter Euler mit Kontaktauflösung, Details in :meth:`step`). + + Args: + width: Breite der Welt in px. + height: Höhe der Welt in px. + config: Physikparameter. + + Attributes: + width: Breite der Welt in px. + height: Höhe der Welt in px. + config: Physikparameter. + bodies: Alle dynamischen Körper in Einfügereihenfolge (später eingefügte liegen beim + Zeichnen oben). + walls: Die vier statischen Wände (links, rechts, oben, unten). + contacts: Die Kontakte des letzten Schritts (für die Debug-Anzeige). + step_count: Anzahl bisher ausgeführter Schritte. + """ + + def __init__(self, width: float, height: float, config: PhysicsConfig) -> None: + if not (width > 0.0 and height > 0.0): + raise ValueError(f"Die Welt braucht eine positive Größe: {width} x {height}") + self.width = width + self.height = height + self.config = config + self.bodies: list[Body] = [] + self.contacts: list[Contact] = [] + self.step_count = 0 + self._gravity_enabled = True + self._solver = ContactSolver(config) + + wall_material = Material( + density=0.0, restitution=config.wall_restitution, friction=config.wall_friction + ) + # Jede Wandnormale zeigt in die Welt hinein; frei ist die Seite normal·p ≥ offset. + self.walls: list[Body] = [ + Body.wall(Vector2(1.0, 0.0), 0.0, wall_material), # links: x ≥ 0 + Body.wall(Vector2(-1.0, 0.0), -width, wall_material), # rechts: x ≤ width + Body.wall(Vector2(0.0, 1.0), 0.0, wall_material), # oben: y ≥ 0 + Body.wall(Vector2(0.0, -1.0), -height, wall_material), # unten: y ≤ height + ] + + # ------------------------------------------------------------------ Schwerkraft + + @property + def gravity_enabled(self) -> bool: + """Ob die Schwerkraft wirkt.""" + return self._gravity_enabled + + @gravity_enabled.setter + def gravity_enabled(self, enabled: bool) -> None: + if enabled != self._gravity_enabled: + self._gravity_enabled = enabled + # Schlafende Körper würden die Änderung sonst nicht bemerken und z. B. ohne + # Schwerkraft am Boden kleben bleiben bzw. mit Schwerkraft in der Luft hängen. + self.wake_all() + + @property + def gravity(self) -> Vector2: + """Aktuell wirkende Schwerkraftbeschleunigung (Nullvektor, wenn abgeschaltet).""" + if not self._gravity_enabled: + return ZERO + return Vector2(*self.config.gravity) + + # ------------------------------------------------------------------ Körperverwaltung + + def add_body(self, body: Body) -> Body: + """Fügt einen dynamischen Körper hinzu und gibt ihn zurück.""" + if body.is_static: + raise ValueError("Statische Körper werden von der Welt selbst verwaltet (Wände)") + self.bodies.append(body) + return body + + def remove_body(self, body: Body) -> None: + """Entfernt einen Körper und weckt alles, was auf ihm gelegen haben könnte. + + Dafür genügt es, seine Schlaf-Insel zu wecken: Schlafende Körper, die einander + berühren, sind immer gemeinsam eingeschlafen und gehören damit zur selben Insel. + """ + self.bodies.remove(body) + body.wake() + + def clear(self) -> None: + """Entfernt alle Körper.""" + self.bodies.clear() + self.contacts = [] + self._solver.reset() + + def wake_all(self) -> None: + """Weckt alle Körper auf.""" + for body in self.bodies: + body.wake() + + def body_at(self, point: Vector2, tolerance: float = 0.0) -> Body | None: + """Oberster Körper an einem Punkt (später eingefügte Körper liegen oben). + + Args: + point: Punkt in Weltkoordinaten. + tolerance: Vergrößert die Körper beim Test, damit kleine Objekte leichter zu + treffen sind. + """ + for body in reversed(self.bodies): + if body.contains_point(point, tolerance): + return body + return None + + def clamp_inside(self, position: Vector2, half_extents: tuple[float, float]) -> Vector2: + """Verschiebt eine Mittelpunktsposition so, dass ein Körper der Größe ganz hineinpasst.""" + half_x, half_y = half_extents + x = min(max(position.x, half_x), self.width - half_x) + y = min(max(position.y, half_y), self.height - half_y) + if x == position.x and y == position.y: + return position + return Vector2(x, y) + + # ------------------------------------------------------------------ Simulation + + def step(self, dt: float) -> None: + """Rückt die Simulation um ``dt`` Sekunden vor. + + Ablauf (semi-impliziter Euler mit Kontaktauflösung): + + 1. Zustand merken: Position und Winkel für die Zeichen-Interpolation, die + Geschwindigkeit für die Aufprallgeschwindigkeit beim Abprallen. + 2. Geschwindigkeiten integrieren: ``v += g·dt`` (plus Dämpfung). + 3. Kontakte suchen: Sweep-and-Prune, dann genaue Tests; schlafende Körper, die + berührt werden, samt ihrer Schlaf-Insel wecken; danach Wandkontakte der wachen + Körper. + 4. Impulse lösen (Restitution, Reibung, Rollwiderstand) und Geschwindigkeiten + begrenzen. + 5. Positionen integrieren: ``x += v·dt`` – mit der *neuen* Geschwindigkeit. Genau + das macht den Euler "semi-implizit" (symplektisch): Anders als beim expliziten + Euler wächst die Energie dabei nicht systematisch an, Umlaufbahnen und + Schwingungen bleiben stabil. + 6. Verbliebene Durchdringungen über die Positionen abbauen. + 7. Notbremse: kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand. + 8. Schlafmodus aktualisieren. + """ + awake = [body for body in self.bodies if not body.sleeping] + self._remember_state(awake) + + self._integrate_velocities(awake, dt) + + contacts = self._find_body_contacts() + woken = wake_touched_bodies(contacts) + self._remember_state(woken) + awake.extend(woken) + contacts.extend(self._find_wall_contacts(awake)) + + self._solver.solve_velocities(contacts) + self._clamp_velocities(awake) + self._integrate_positions(awake, dt) + self._solver.correct_positions() + self._enforce_bounds(awake) + + if self.config.sleep_enabled: + update_sleep(awake, contacts, dt, self.config) + self.contacts = contacts + self.step_count += 1 + + @staticmethod + def _remember_state(bodies: list[Body]) -> None: + """Merkt sich den Zustand zu Beginn des Schritts (siehe ``Body.previous_*``).""" + for body in bodies: + body.previous_position = body.position + body.previous_angle = body.angle + body.previous_velocity = body.velocity + + def _integrate_velocities(self, bodies: list[Body], dt: float) -> None: + """Wendet Schwerkraft und Dämpfung auf die Geschwindigkeiten an.""" + gravity = self.gravity + gravity_dx = gravity.x * dt + gravity_dy = gravity.y * dt + # Dämpfung als v ← v / (1 + c·dt): Näherung von v·e^(−c·dt), die anders als + # v·(1 − c·dt) auch bei großem c·dt nie das Vorzeichen umkehrt. + linear_factor = 1.0 / (1.0 + dt * self.config.linear_damping) + angular_factor = 1.0 / (1.0 + dt * self.config.angular_damping) + for body in bodies: + if body.held: + continue # Die Geschwindigkeit gehaltener Körper gibt die Maus vor. + velocity = body.velocity + body.velocity = Vector2( + (velocity.x + gravity_dx) * linear_factor, + (velocity.y + gravity_dy) * linear_factor, + ) + body.angular_velocity *= angular_factor + + def _find_body_contacts(self) -> list[Contact]: + """Kontakte zwischen dynamischen Körpern (grobe Vorauswahl, dann genauer Test).""" + contacts: list[Contact] = [] + for body_a, body_b in find_candidate_pairs(self.bodies): + contact = collide(body_a, body_b) + if contact is not None: + contacts.append(contact) + return contacts + + def _find_wall_contacts(self, bodies: list[Body]) -> list[Contact]: + """Kontakte der wachen Körper mit den Wänden.""" + left, right, top, bottom = self.walls + width = self.width + height = self.height + contacts: list[Contact] = [] + for body in bodies: + min_x, min_y, max_x, max_y = body.bounds() + # Billiger Vortest über die umschließende Box, genauer Test nur bei Bedarf. + for touches, wall in ( + (min_x <= 0.0, left), + (max_x >= width, right), + (min_y <= 0.0, top), + (max_y >= height, bottom), + ): + if touches: + contact = collide(body, wall) + if contact is not None: + contacts.append(contact) + return contacts + + def _clamp_velocities(self, bodies: list[Body]) -> None: + """Begrenzt Geschwindigkeit und Drehung (Schutz vor Tunneln und Explosionen).""" + max_speed = self.config.max_speed + max_speed_sq = max_speed * max_speed + max_angular = self.config.max_angular_speed + for body in bodies: + velocity = body.velocity + speed_sq = velocity.length_squared() + if speed_sq > max_speed_sq: + body.velocity = velocity * (max_speed / math.sqrt(speed_sq)) + if abs(body.angular_velocity) > max_angular: + body.angular_velocity = math.copysign(max_angular, body.angular_velocity) + + def _integrate_positions(self, bodies: list[Body], dt: float) -> None: + """``x += v·dt`` und ``θ += ω·dt`` mit den bereits aktualisierten Geschwindigkeiten.""" + full_turn = math.tau + for body in bodies: + velocity = body.velocity + position = body.position + body.position = Vector2(position.x + velocity.x * dt, position.y + velocity.y * dt) + if body.angular_velocity != 0.0: + # Winkel im Bereich [−π, π] halten, damit er über lange Zeit nicht an + # Genauigkeit verliert. + body.angle = math.remainder(body.angle + body.angular_velocity * dt, full_turn) + + def _enforce_bounds(self, bodies: list[Body]) -> None: + """Notbremse: Kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand. + + Im Normalfall halten die Wandkontakte die Körper in der Welt. Die Grenze liegt über + der größten Eindringtiefe eines frei fliegenden Körpers pro Schritt und greift daher + nur in Extremfällen, etwa wenn ein Körper von einem gezogenen Objekt gegen die Wand + gequetscht wird. Geschwindigkeiten bleiben unangetastet: Den eigentlichen Stoß mit + der Wand löst im nächsten Schritt der Solver. + """ + allowed = self.config.max_wall_penetration + for body in bodies: + half_x, half_y = body.half_extents() + position = body.position + x = min(max(position.x, half_x - allowed), self.width - half_x + allowed) + y = min(max(position.y, half_y - allowed), self.height - half_y + allowed) + if x != position.x or y != position.y: + body.position = Vector2(x, y) diff --git a/src/sandbox/renderer.py b/src/sandbox/renderer.py new file mode 100644 index 0000000..b018da5 --- /dev/null +++ b/src/sandbox/renderer.py @@ -0,0 +1,243 @@ +"""Zeichnet die Physikwelt: Hintergrund, Körper, Hervorhebungen, Vorschau und Debug-Ansicht.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +import pygame + +from sandbox.config import Color +from sandbox.interaction import BodyStyle, ShapeKind +from sandbox.physics import ZERO, Body, Circle, Vector2, World +from sandbox.ui.drawing import draw_box, draw_circle +from sandbox.ui.style import UiStyle, shade +from sandbox.viewport import Viewport + + +@dataclass(frozen=True) +class SpawnPreview: + """Halbtransparente Vorschau des Objekts, das ein Klick erzeugen würde. + + Attributes: + shape: Form des Objekts. + half_extents: Halbe Breite und Höhe in px. + color: Farbe des Objekts. + screen_center: Mittelpunkt auf dem Bildschirm (kann über der Sidebar liegen, + wenn ein Objekt gerade aus der Vorschau-Kachel gezogen wird). + """ + + shape: ShapeKind + half_extents: tuple[float, float] + color: Color + screen_center: tuple[float, float] + + +@dataclass +class WorldOverlay: + """Zustand der Bedienung, der über der Welt angezeigt wird. + + Attributes: + hovered: Körper unter der Maus. + held: Gezogener Körper. + throw_velocity: Aktuelle Wurfgeschwindigkeit des gezogenen Körpers. + paused: Ob die Simulation pausiert ist. + debug: Ob die Debug-Ansicht (Kontakte, Schlafzustand) aktiv ist. + debug_lines: Textzeilen der Debug-Ansicht. + """ + + hovered: Body | None = None + held: Body | None = None + throw_velocity: Vector2 = ZERO + paused: bool = False + debug: bool = False + debug_lines: list[str] = field(default_factory=list) + + +def interpolate_angle(previous: float, current: float, alpha: float) -> float: + """Interpoliert einen Winkel auf dem kürzeren Weg. + + Der Winkel wird in der Physik auf [−π, π] gehalten. Springt er von +3,1 auf −3,1, hat + sich der Körper nur um 0,08 rad gedreht – naive Interpolation würde ihn für einen Frame + fast eine ganze Umdrehung zurückdrehen lassen. + """ + return previous + math.remainder(current - previous, math.tau) * alpha + + +class Renderer: + """Zeichnet die Welt in ihren Bildschirmbereich. + + Args: + screen: Zielfläche (das Fenster). + viewport: Lage der Welt auf dem Bildschirm. + style: Farben, Schriften, Maße. + """ + + def __init__(self, screen: pygame.Surface, viewport: Viewport, style: UiStyle) -> None: + self.screen = screen + self.viewport = viewport + self.style = style + self._background = self._render_background() + + def _render_background(self) -> pygame.Surface: + """Hintergrund mit Hilfsgitter – einmal vorberechnet statt jedes Bild neu.""" + theme = self.style.theme + spacing = self.style.ui.grid_spacing + width, height = self.viewport.width, self.viewport.height + surface = pygame.Surface((width, height)) + surface.fill(theme.world_background) + for x in range(spacing, width, spacing): + pygame.draw.line(surface, theme.grid, (x, 0), (x, height)) + for y in range(spacing, height, spacing): + pygame.draw.line(surface, theme.grid, (0, y), (width, y)) + return surface + + # ------------------------------------------------------------------ Welt + + def draw_world(self, world: World, alpha: float, overlay: WorldOverlay) -> None: + """Zeichnet Hintergrund, alle Körper und die Bedien-Hinweise. + + Args: + world: Die Welt. + alpha: Interpolationsgewicht zwischen letztem und aktuellem Physikzustand. + overlay: Hervorhebungen und Zusatzanzeigen. + """ + screen = self.screen + area = pygame.Rect(self.viewport.rect) + screen.blit(self._background, area.topleft) + screen.set_clip(area) # Körper, die leicht in eine Wand ragen, nicht über die Sidebar + for body in world.bodies: + self._draw_body(body, alpha, overlay) + if overlay.held is not None: + self._draw_throw_arrow(overlay.held, overlay.throw_velocity) + if overlay.debug: + self._draw_debug(world, overlay.debug_lines) + screen.set_clip(None) + pygame.draw.rect(screen, self.style.theme.world_border, area, self.style.ui.border_width) + if overlay.paused: + self._draw_pause_badge() + + def _draw_body(self, body: Body, alpha: float, overlay: WorldOverlay) -> None: + theme = self.style.theme + ui = self.style.ui + # Zwischen zwei Physikschritten interpolieren: Die Physik läuft mit 120 Hz, der + # Bildschirm z. B. mit 60 Hz – ohne Interpolation ruckelt die Bewegung sichtbar. + position = body.previous_position.lerp(body.position, alpha) + center = self.viewport.to_screen(position) + style = body.user_data + color = style.color if isinstance(style, BodyStyle) else theme.body_palette[0] + outline = shade(color, theme.outline_shade) + if body.sleeping and overlay.debug: + outline = theme.debug_sleeping + + if body is overlay.held: + highlight: Color | None = theme.held_outline + elif body is overlay.hovered: + highlight = theme.highlight + else: + highlight = None + + shape = body.shape + if isinstance(shape, Circle): + angle = interpolate_angle(body.previous_angle, body.angle, alpha) + draw_circle( + self.screen, + color, + outline, + center, + shape.radius, + outline_width=ui.outline_width, + angle=angle, + marker_length=ui.marker_length, + ) + if highlight is not None: + pygame.draw.aacircle( + self.screen, highlight, center, shape.radius, ui.highlight_width + ) + else: + half_extents = shape.half_extents() + draw_box( + self.screen, color, outline, center, half_extents, outline_width=ui.outline_width + ) + if highlight is not None: + draw_box( + self.screen, + color, + highlight, + center, + half_extents, + outline_width=ui.highlight_width, + ) + + def _draw_throw_arrow(self, body: Body, velocity: Vector2) -> None: + """Linie in Wurfrichtung; ihre Länge zeigt, wie weit der Körper in kurzer Zeit flöge.""" + if velocity.length_squared() == 0.0: + return + ui = self.style.ui + start = self.viewport.to_screen(body.position) + end = ( + start[0] + velocity.x * ui.throw_arrow_time, + start[1] + velocity.y * ui.throw_arrow_time, + ) + color = self.style.theme.throw_arrow + pygame.draw.line(self.screen, color, start, end, ui.outline_width) + pygame.draw.aacircle(self.screen, color, end, ui.debug_contact_radius) + + def _draw_debug(self, world: World, lines: list[str]) -> None: + """Kontaktpunkte, Kontaktnormalen und Kennzahlen.""" + theme = self.style.theme + ui = self.style.ui + for contact in world.contacts: + point = self.viewport.to_screen(contact.point) + end = ( + point[0] + contact.normal.x * ui.debug_normal_length, + point[1] + contact.normal.y * ui.debug_normal_length, + ) + pygame.draw.line(self.screen, theme.debug_normal, point, end) + pygame.draw.circle(self.screen, theme.debug_contact, point, ui.debug_contact_radius) + x = self.viewport.left + ui.padding + y = self.viewport.top + ui.padding + for line in lines: + rendered = self.style.render_text(line, self.style.small_font, theme.text) + self.screen.blit(rendered, (x, y)) + y += rendered.get_height() + + def _draw_pause_badge(self) -> None: + theme = self.style.theme + ui = self.style.ui + text = self.style.render_text( + "PAUSE – Leertaste zum Fortsetzen", self.style.label_font, theme.text + ) + badge = text.get_rect().inflate(2 * ui.padding, ui.padding) + badge.midtop = (self.viewport.left + self.viewport.width // 2, ui.padding) + pygame.draw.rect(self.screen, theme.panel_active, badge, border_radius=ui.corner_radius) + self.screen.blit(text, text.get_rect(center=badge.center)) + + # ------------------------------------------------------------------ Vorschau + + def draw_preview(self, preview: SpawnPreview) -> None: + """Zeichnet die halbtransparente Vorschau eines neuen Objekts.""" + theme = self.style.theme + ui = self.style.ui + half_width, half_height = preview.half_extents + margin = ui.highlight_width + size = (math.ceil(2 * half_width) + 2 * margin, math.ceil(2 * half_height) + 2 * margin) + layer = pygame.Surface(size, pygame.SRCALPHA) + fill = (*preview.color, theme.preview_alpha) + outline = (*theme.highlight, theme.preview_alpha) + local_center = (size[0] / 2, size[1] / 2) + if preview.shape is ShapeKind.CIRCLE: + pygame.draw.aacircle(layer, fill, local_center, half_width) + pygame.draw.aacircle(layer, outline, local_center, half_width, ui.outline_width) + else: + draw_box( + layer, + fill, + outline, + local_center, + preview.half_extents, + outline_width=ui.outline_width, + ) + self.screen.blit( + layer, (preview.screen_center[0] - size[0] / 2, preview.screen_center[1] - size[1] / 2) + ) diff --git a/src/sandbox/ui/__init__.py b/src/sandbox/ui/__init__.py new file mode 100644 index 0000000..9987320 --- /dev/null +++ b/src/sandbox/ui/__init__.py @@ -0,0 +1 @@ +"""Bedienoberfläche: Widgets und die Werkzeugleiste (Sidebar).""" diff --git a/src/sandbox/ui/drawing.py b/src/sandbox/ui/drawing.py new file mode 100644 index 0000000..7875c6b --- /dev/null +++ b/src/sandbox/ui/drawing.py @@ -0,0 +1,65 @@ +"""Zeichenhelfer für Körperformen – gemeinsam genutzt von Renderer und Vorschau-Kachel.""" + +from __future__ import annotations + +import math +from typing import TypeAlias + +import pygame + +#: RGB- oder RGBA-Farbe (RGBA für halbtransparente Vorschauen). +DrawColor: TypeAlias = tuple[int, ...] + + +def draw_circle( + surface: pygame.Surface, + color: DrawColor, + outline: DrawColor, + center: tuple[float, float], + radius: float, + *, + outline_width: int, + angle: float | None = None, + marker_length: float = 0.0, +) -> None: + """Zeichnet einen geglätteten Kreis mit Umrandung und optionalem Drehmarker. + + Args: + surface: Zielfläche. + color: Füllfarbe. + outline: Farbe von Umrandung und Drehmarker. + center: Mittelpunkt in Bildschirmkoordinaten. + radius: Radius in px. + outline_width: Linienstärke der Umrandung. + angle: Drehwinkel in rad. Ohne Marker wäre die Drehung eines einfarbigen Kreises + unsichtbar – man sähe nicht, ob er rollt oder rutscht. + marker_length: Länge des Markers als Anteil des Radius. + """ + # Die Füllung braucht keine Kantenglättung: Die geglättete Umrandung liegt genau auf + # ihrem Rand und verdeckt die Treppenstufen. Das halbiert die teuren aacircle-Aufrufe. + pygame.draw.circle(surface, color, center, radius) + pygame.draw.aacircle(surface, outline, center, radius, outline_width) + if angle is not None: + end = ( + center[0] + math.cos(angle) * radius * marker_length, + center[1] + math.sin(angle) * radius * marker_length, + ) + pygame.draw.line(surface, outline, center, end, outline_width) + + +def draw_box( + surface: pygame.Surface, + color: DrawColor, + outline: DrawColor, + center: tuple[float, float], + half_extents: tuple[float, float], + *, + outline_width: int, + corner_radius: int = 0, +) -> None: + """Zeichnet ein achsenparalleles Rechteck mit Umrandung.""" + half_width, half_height = half_extents + rect = pygame.Rect(0, 0, round(2 * half_width), round(2 * half_height)) + rect.center = (round(center[0]), round(center[1])) + pygame.draw.rect(surface, color, rect, border_radius=corner_radius) + pygame.draw.rect(surface, outline, rect, outline_width, border_radius=corner_radius) diff --git a/src/sandbox/ui/sidebar.py b/src/sandbox/ui/sidebar.py new file mode 100644 index 0000000..bf3cbc2 --- /dev/null +++ b/src/sandbox/ui/sidebar.py @@ -0,0 +1,298 @@ +"""Werkzeugleiste am linken Fensterrand. Sie gehört nicht zur Physikwelt.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import partial +from typing import Protocol + +import pygame + +from sandbox.config import Color, InteractionConfig +from sandbox.interaction import ShapeKind, ToolSettings +from sandbox.ui.style import UiStyle +from sandbox.ui.widgets import ( + Button, + ColorPicker, + Label, + LabelKind, + SegmentedControl, + Slider, + SpawnTile, + Widget, +) + + +class SidebarControls(Protocol): + """Was die Sidebar von der Anwendung braucht (erfüllt von :class:`~sandbox.main.App`).""" + + def is_paused(self) -> bool: + """Ob die Simulation pausiert ist.""" + ... + + def toggle_pause(self) -> None: + """Pausiert bzw. setzt fort.""" + ... + + def gravity_enabled(self) -> bool: + """Ob die Schwerkraft wirkt.""" + ... + + def toggle_gravity(self) -> None: + """Schaltet die Schwerkraft um.""" + ... + + def clear_world(self) -> None: + """Entfernt alle Körper.""" + ... + + def begin_spawn_drag(self) -> None: + """Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel.""" + ... + + def preview_color(self) -> Color: + """Farbe des nächsten Objekts.""" + ... + + def status_lines(self) -> list[str]: + """Statuszeilen für den unteren Rand (Objektzahl, FPS …).""" + ... + + +def _format_size(value: float) -> str: + return f"{value:.0f} px" + + +def _format_ratio(value: float) -> str: + return f"{value:.2f}" + + +def _format_density(value: float) -> str: + return f"{value:.1f}" + + +class Sidebar: + """Baut die Bedienelemente auf, verteilt Events an sie und zeichnet sie. + + Args: + rect: Bereich der Sidebar auf dem Bildschirm. + style: Farben, Schriften, Maße. + tools: Die Werkzeug-Einstellungen, die die Regler verändern. + limits: Wertebereiche der Regler. + palette: Farbpalette. + controls: Anbindung an die Anwendung. + """ + + def __init__( + self, + rect: pygame.Rect, + style: UiStyle, + tools: ToolSettings, + limits: InteractionConfig, + palette: tuple[Color, ...], + controls: SidebarControls, + ) -> None: + self.rect = pygame.Rect(rect) + self.style = style + self.tools = tools + self.controls = controls + self.widgets: list[Widget] = [] + self._status_top = 0 + self._build(limits, palette) + + # ------------------------------------------------------------------ Aufbau + + def _build(self, limits: InteractionConfig, palette: tuple[Color, ...]) -> None: + """Ordnet die Widgets von oben nach unten an.""" + ui = self.style.ui + tools = self.tools + controls = self.controls + left = self.rect.x + ui.padding + width = self.rect.width - 2 * ui.padding + y = self.rect.y + ui.padding + + def row(height: int) -> pygame.Rect: + return pygame.Rect(left, y, width, height) + + def add(widget: Widget, gap: int = ui.row_gap) -> None: + nonlocal y + self.widgets.append(widget) + y = widget.rect.bottom + gap + + def slider( + name: str, + label: str, + value_format: Callable[[float], str], + *, + minimum: float, + maximum: float, + step: float, + is_enabled: Callable[[], bool] | None = None, + ) -> Slider: + # Der Regler liest und schreibt direkt das gleichnamige Attribut der Einstellungen. + return Slider( + row(ui.slider_height), + label, + minimum=minimum, + maximum=maximum, + step=step, + get_value=partial(getattr, tools, name), + set_value=partial(setattr, tools, name), + value_format=value_format, + knob_radius=ui.slider_knob_radius, + is_enabled=is_enabled, + name=name, + ) + + add(Label(row(ui.header_height + ui.padding), "Physik-Sandbox", LabelKind.TITLE)) + + add(Label(row(ui.header_height), "OBJEKT", LabelKind.HEADER)) + add( + SegmentedControl( + row(ui.button_height), + [(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")], + partial(getattr, tools, "shape"), + partial(setattr, tools, "shape"), + name="shape", + ) + ) + add( + slider( + "size", + "Größe", + _format_size, + minimum=limits.min_size, + maximum=limits.max_size, + step=limits.size_step, + ) + ) + add( + slider( + "aspect_ratio", + "Seitenverhältnis", + _format_ratio, + minimum=limits.min_aspect_ratio, + maximum=limits.max_aspect_ratio, + step=limits.aspect_ratio_step, + is_enabled=lambda: tools.shape is ShapeKind.BOX, + ), + ui.section_gap, + ) + + add(Label(row(ui.header_height), "MATERIAL", LabelKind.HEADER)) + add( + slider( + "restitution", + "Elastizität", + _format_ratio, + minimum=0.0, + maximum=limits.max_restitution, + step=limits.restitution_step, + ) + ) + add( + slider( + "density", + "Dichte", + _format_density, + minimum=limits.min_density, + maximum=limits.max_density, + step=limits.density_step, + ) + ) + add(Label(row(ui.header_height), "FARBE", LabelKind.HEADER)) + add( + ColorPicker( + row(ui.swatch_size), + palette, + partial(getattr, tools, "color_index"), + partial(setattr, tools, "color_index"), + swatch_size=ui.swatch_size, + swatch_gap=ui.swatch_gap, + name="color", + ), + ui.section_gap, + ) + add( + SpawnTile( + row(ui.spawn_tile_height), + lambda: tools, + controls.preview_color, + controls.begin_spawn_drag, + hint="Ziehen oder in die Welt klicken", + name="spawn_tile", + ), + ui.section_gap, + ) + + add(Label(row(ui.header_height), "SIMULATION", LabelKind.HEADER)) + half = (width - ui.row_gap) // 2 + self.widgets.append( + Button( + pygame.Rect(left, y, half, ui.button_height), + lambda: "Weiter" if controls.is_paused() else "Pause", + controls.toggle_pause, + is_active=controls.is_paused, + name="pause", + ) + ) + add( + Button( + pygame.Rect( + left + half + ui.row_gap, y, width - half - ui.row_gap, ui.button_height + ), + lambda: "Schwerkraft an" if controls.gravity_enabled() else "Schwerkraft aus", + controls.toggle_gravity, + is_active=controls.gravity_enabled, + name="gravity", + ) + ) + add( + Button( + row(ui.button_height), + "Alles löschen", + controls.clear_world, + danger=True, + name="clear", + ), + ui.section_gap, + ) + self._status_top = y + + # ------------------------------------------------------------------ Laufzeit + + def widget(self, name: str) -> Widget: + """Findet ein Widget über seinen Namen.""" + for widget in self.widgets: + if widget.name == name: + return widget + raise KeyError(name) + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Leitet ein Event an die Widgets weiter. + + Returns: + True, wenn ein Widget das Event verbraucht hat oder ein Klick auf die Sidebar + fiel – solche Klicks dürfen nie in der Welt landen. + """ + if event.type == pygame.MOUSEMOTION: + # Alle Widgets sehen jede Mausbewegung (Hover-Zustand, gezogene Regler). + results = [widget.handle_event(event, mouse_pos) for widget in self.widgets] + return any(results) + for widget in self.widgets: + if widget.handle_event(event, mouse_pos): + return True + is_click = event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP) + return is_click and self.rect.collidepoint(mouse_pos) + + def draw(self, surface: pygame.Surface) -> None: + """Zeichnet Hintergrund, Widgets und Statuszeilen.""" + style = self.style + pygame.draw.rect(surface, style.theme.sidebar_background, self.rect) + for widget in self.widgets: + widget.draw(surface, style) + y = self._status_top + for line in self.controls.status_lines(): + rendered = style.render_text(line, style.small_font, style.theme.text_dim) + surface.blit(rendered, (self.rect.x + style.ui.padding, y)) + y += rendered.get_height() diff --git a/src/sandbox/ui/style.py b/src/sandbox/ui/style.py new file mode 100644 index 0000000..deda35d --- /dev/null +++ b/src/sandbox/ui/style.py @@ -0,0 +1,59 @@ +"""Gemeinsamer Zeichenstil: Farben, Schriften und ein kleiner Text-Cache.""" + +from __future__ import annotations + +import pygame + +from sandbox.config import Color, Theme, UiConfig + + +def _load_font(names: tuple[str, ...], size: int, *, bold: bool = False) -> pygame.font.Font: + """Lädt die erste verfügbare Systemschrift, sonst die eingebaute pygame-Schrift.""" + for name in names: + path = pygame.font.match_font(name) + if path: + font = pygame.font.Font(path, size) + break + else: + font = pygame.font.Font(None, size) + font.bold = bold + return font + + +class UiStyle: + """Farben, Maße und Schriften, die alle Widgets und der Renderer gemeinsam nutzen. + + Args: + theme: Farbschema. + ui: Schriften und Maße. + """ + + def __init__(self, theme: Theme, ui: UiConfig) -> None: + if not pygame.font.get_init(): + pygame.font.init() + self.theme = theme + self.ui = ui + self.title_font = _load_font(ui.font_names, ui.title_font_size, bold=True) + self.label_font = _load_font(ui.font_names, ui.label_font_size) + self.small_font = _load_font(ui.font_names, ui.small_font_size) + self._text_cache: dict[tuple[int, str, Color], pygame.Surface] = {} + + def render_text(self, text: str, font: pygame.font.Font, color: Color) -> pygame.Surface: + """Rendert Text (mit Zwischenspeicher, da sich die meisten Texte selten ändern).""" + key = (id(font), text, color) + surface = self._text_cache.get(key) + if surface is None: + if len(self._text_cache) >= self.ui.text_cache_size: + self._text_cache.clear() # einfach und ausreichend: selten, dann komplett + surface = font.render(text, True, color) + self._text_cache[key] = surface + return surface + + +def shade(color: Color, factor: float) -> Color: + """Verdunkelt (factor < 1) oder erhellt (factor > 1) eine Farbe.""" + return ( + max(0, min(255, round(color[0] * factor))), + max(0, min(255, round(color[1] * factor))), + max(0, min(255, round(color[2] * factor))), + ) diff --git a/src/sandbox/ui/widgets.py b/src/sandbox/ui/widgets.py new file mode 100644 index 0000000..2a03ff6 --- /dev/null +++ b/src/sandbox/ui/widgets.py @@ -0,0 +1,500 @@ +"""Selbst gezeichnete Bedienelemente: Beschriftung, Button, Umschalter, Regler, Farbwahl. + +Jedes Widget hat ein Rechteck, verarbeitet pygame-Events (:meth:`Widget.handle_event` +liefert True, wenn es das Event verbraucht hat) und zeichnet sich selbst. Zustände wie +"ist die Pause aktiv?" fragen die Widgets über Callbacks ab, statt sie zu kopieren – so +zeigen sie immer den aktuellen Stand, auch wenn er per Tastenkürzel geändert wurde. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from enum import Enum +from typing import Generic, TypeAlias, TypeVar + +import pygame + +from sandbox.config import Color +from sandbox.interaction import ShapeKind, ToolSettings +from sandbox.ui.drawing import draw_box, draw_circle +from sandbox.ui.style import UiStyle, shade + +T = TypeVar("T") + +#: Fester Text oder Funktion, die den aktuellen Text liefert. +TextSource: TypeAlias = str | Callable[[], str] + + +def _resolve(text: TextSource) -> str: + return text() if callable(text) else text + + +def _is_left_press(event: pygame.event.Event, rect: pygame.Rect) -> bool: + return ( + event.type == pygame.MOUSEBUTTONDOWN + and event.button == pygame.BUTTON_LEFT + and rect.collidepoint(event.pos) + ) + + +def _is_left_release(event: pygame.event.Event) -> bool: + return event.type == pygame.MOUSEBUTTONUP and event.button == pygame.BUTTON_LEFT + + +class Widget: + """Basisklasse aller Bedienelemente. + + Args: + rect: Position und Größe auf dem Bildschirm. + name: Eindeutiger Name, über den z. B. Tests das Widget finden. + """ + + def __init__(self, rect: pygame.Rect, name: str = "") -> None: + self.rect = pygame.Rect(rect) + self.name = name + self.hovered = False + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Verarbeitet ein Event; True, wenn das Widget es verbraucht hat.""" + if event.type == pygame.MOUSEMOTION: + self.hovered = self.rect.collidepoint(mouse_pos) + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet das Widget.""" + raise NotImplementedError + + +class LabelKind(Enum): + """Darstellungsart einer Beschriftung.""" + + TITLE = "title" + HEADER = "header" + TEXT = "text" + HINT = "hint" + + +class Label(Widget): + """Einzeilige Beschriftung (Text fest oder dynamisch über eine Funktion).""" + + def __init__( + self, rect: pygame.Rect, text: TextSource, kind: LabelKind = LabelKind.TEXT, name: str = "" + ) -> None: + super().__init__(rect, name) + self.text = text + self.kind = kind + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet den Text linksbündig und vertikal zentriert.""" + theme = style.theme + font, color = { + LabelKind.TITLE: (style.title_font, theme.text), + LabelKind.HEADER: (style.small_font, theme.text_dim), + LabelKind.TEXT: (style.label_font, theme.text), + LabelKind.HINT: (style.small_font, theme.text_dim), + }[self.kind] + rendered = style.render_text(_resolve(self.text), font, color) + surface.blit(rendered, (self.rect.x, self.rect.centery - rendered.get_height() // 2)) + + +class Button(Widget): + """Schaltfläche. Löst beim Loslassen über dem Button aus (übliches UI-Verhalten). + + Args: + rect: Position und Größe. + text: Beschriftung (fest oder dynamisch). + on_click: Wird beim Klick aufgerufen. + is_active: Optional; hebt den Button hervor, solange die Funktion True liefert. + danger: Rote Darstellung für zerstörerische Aktionen. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + text: TextSource, + on_click: Callable[[], None], + *, + is_active: Callable[[], bool] | None = None, + danger: bool = False, + name: str = "", + ) -> None: + super().__init__(rect, name) + self.text = text + self.on_click = on_click + self.is_active = is_active + self.danger = danger + self.pressed = False + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Drücken merkt sich den Button, Loslassen über dem Button löst aus.""" + super().handle_event(event, mouse_pos) + if _is_left_press(event, self.rect): + self.pressed = True + return True + if _is_left_release(event) and self.pressed: + self.pressed = False + if self.rect.collidepoint(event.pos): + self.on_click() + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet Hintergrund (je nach Zustand) und zentrierten Text.""" + theme = style.theme + active = self.is_active is not None and self.is_active() + if active: + background = theme.panel_active + elif self.hovered: + background = theme.panel_hover + else: + background = theme.panel + radius = style.ui.corner_radius + pygame.draw.rect(surface, background, self.rect, border_radius=radius) + text_color = theme.danger if self.danger else theme.text + if self.danger or active: + border = theme.danger if self.danger else theme.accent + pygame.draw.rect( + surface, border, self.rect, style.ui.border_width, border_radius=radius + ) + rendered = style.render_text(_resolve(self.text), style.label_font, text_color) + surface.blit(rendered, rendered.get_rect(center=self.rect.center)) + + +class SegmentedControl(Widget, Generic[T]): + """Reihe gleich breiter Optionen, von denen genau eine gewählt ist. + + Args: + rect: Position und Größe. + options: Paare aus Wert und Beschriftung. + get_value: Liefert den aktuell gewählten Wert. + set_value: Setzt einen neuen Wert. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + options: Sequence[tuple[T, str]], + get_value: Callable[[], T], + set_value: Callable[[T], None], + name: str = "", + ) -> None: + super().__init__(rect, name) + self.options = list(options) + self.get_value = get_value + self.set_value = set_value + + def segment_rects(self) -> list[pygame.Rect]: + """Rechtecke der einzelnen Optionen.""" + count = len(self.options) + rects = [] + for index in range(count): + left = self.rect.x + self.rect.width * index // count + right = self.rect.x + self.rect.width * (index + 1) // count + rects.append(pygame.Rect(left, self.rect.y, right - left, self.rect.height)) + return rects + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Ein Klick auf eine Option wählt sie sofort aus.""" + super().handle_event(event, mouse_pos) + for (value, _), rect in zip(self.options, self.segment_rects(), strict=True): + if _is_left_press(event, rect): + self.set_value(value) + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet alle Optionen, die gewählte hervorgehoben.""" + theme = style.theme + radius = style.ui.corner_radius + pygame.draw.rect(surface, theme.panel, self.rect, border_radius=radius) + current = self.get_value() + for (value, text), rect in zip(self.options, self.segment_rects(), strict=True): + selected = value == current + if selected: + pygame.draw.rect(surface, theme.panel_active, rect, border_radius=radius) + pygame.draw.rect( + surface, theme.accent, rect, style.ui.border_width, border_radius=radius + ) + color = theme.text if selected else theme.text_dim + rendered = style.render_text(text, style.label_font, color) + surface.blit(rendered, rendered.get_rect(center=rect.center)) + + +class Slider(Widget): + """Schieberegler mit Beschriftung und Wertanzeige. + + Oben stehen Beschriftung und aktueller Wert, darunter die Schiene mit Knopf. Der Wert + lässt sich klicken, ziehen oder mit dem Mausrad (in ``step``-Schritten) ändern. + + Args: + rect: Position und Größe. + label: Beschriftung. + minimum: Kleinster Wert. + maximum: Größter Wert. + step: Rasterung der Werte. + get_value: Liefert den aktuellen Wert. + set_value: Setzt einen neuen Wert. + value_format: Formatiert den Wert für die Anzeige. + knob_radius: Radius des Knopfs in px (bestimmt auch den Rand der Schiene). + is_enabled: Optional; ein deaktivierter Regler ist ausgegraut und reagiert nicht. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + label: str, + *, + minimum: float, + maximum: float, + step: float, + get_value: Callable[[], float], + set_value: Callable[[float], None], + value_format: Callable[[float], str], + knob_radius: int, + is_enabled: Callable[[], bool] | None = None, + name: str = "", + ) -> None: + super().__init__(rect, name) + if not (maximum > minimum and step > 0.0): + raise ValueError("Der Regler braucht maximum > minimum und step > 0") + self.label = label + self.minimum = minimum + self.maximum = maximum + self.step = step + self.get_value = get_value + self.set_value = set_value + self.value_format = value_format + self.knob_radius = knob_radius + self.is_enabled = is_enabled + self.dragging = False + + @property + def enabled(self) -> bool: + """Ob der Regler bedienbar ist.""" + return self.is_enabled is None or self.is_enabled() + + @property + def track_left(self) -> int: + """x-Koordinate des linken Schienenendes.""" + return self.rect.x + self.knob_radius + + @property + def track_width(self) -> int: + """Breite der Schiene in px.""" + return self.rect.width - 2 * self.knob_radius + + @property + def track_y(self) -> int: + """y-Koordinate der Schienenmitte.""" + return self.rect.bottom - self.knob_radius + + def snap(self, value: float) -> float: + """Rastet einen Wert auf das Raster ein und begrenzt ihn auf [minimum, maximum].""" + steps = round((value - self.minimum) / self.step) + snapped = self.minimum + steps * self.step + return min(max(snapped, self.minimum), self.maximum) + + def value_at(self, x: float) -> float: + """Wert, der zur Bildschirmposition ``x`` auf der Schiene gehört.""" + fraction = (x - self.track_left) / self.track_width + fraction = min(max(fraction, 0.0), 1.0) + return self.snap(self.minimum + fraction * (self.maximum - self.minimum)) + + def x_of(self, value: float) -> float: + """Bildschirmposition ``x`` eines Werts (Umkehrung von :meth:`value_at`).""" + fraction = (value - self.minimum) / (self.maximum - self.minimum) + return self.track_left + fraction * self.track_width + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Klicken, Ziehen und Mausrad ändern den Wert.""" + super().handle_event(event, mouse_pos) + if not self.enabled: + self.dragging = False + return _is_left_press(event, self.rect) # Klick verschlucken, nichts tun + if _is_left_press(event, self.rect): + self.dragging = True + self.set_value(self.value_at(event.pos[0])) + return True + if event.type == pygame.MOUSEMOTION and self.dragging: + self.set_value(self.value_at(mouse_pos[0])) + return True + if _is_left_release(event) and self.dragging: + self.dragging = False + return True + if event.type == pygame.MOUSEWHEEL and self.rect.collidepoint(mouse_pos): + self.set_value(self.snap(self.get_value() + event.y * self.step)) + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet Beschriftung, Wert, Schiene und Knopf.""" + theme = style.theme + enabled = self.enabled + text_color = theme.text if enabled else theme.text_dim + label = style.render_text(self.label, style.label_font, text_color) + surface.blit(label, (self.rect.x, self.rect.y)) + value = style.render_text( + self.value_format(self.get_value()), style.small_font, theme.text_dim + ) + # Unterkanten bündig: Der kleinere Wert steht so auf derselben Grundlinie wie der Text. + label_bottom = self.rect.y + label.get_height() + surface.blit(value, value.get_rect(bottomright=(self.rect.right, label_bottom))) + + track = pygame.Rect(0, 0, self.track_width, style.ui.slider_track_height) + track.midleft = (self.track_left, self.track_y) + pygame.draw.rect(surface, theme.panel_hover, track, border_radius=track.height) + knob_x = round(self.x_of(self.get_value())) + filled = track.copy() + filled.width = max(0, knob_x - track.x) + fill_color = theme.accent if enabled else theme.text_dim + pygame.draw.rect(surface, fill_color, filled, border_radius=track.height) + knob_color = theme.text if (self.hovered or self.dragging) and enabled else fill_color + pygame.draw.aacircle(surface, knob_color, (knob_x, self.track_y), self.knob_radius) + + +class ColorPicker(Widget): + """Farbfelder zur Auswahl; das erste Feld steht für "Zufallsfarbe". + + Args: + rect: Position und Größe. + colors: Farbpalette. + get_index: Liefert den gewählten Palettenindex (``None`` = Zufall). + set_index: Setzt den Palettenindex (``None`` = Zufall). + swatch_size: Kantenlänge eines Farbfelds in px. + swatch_gap: Abstand zwischen den Feldern in px. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + colors: Sequence[Color], + get_index: Callable[[], int | None], + set_index: Callable[[int | None], None], + *, + swatch_size: int, + swatch_gap: int, + name: str = "", + ) -> None: + super().__init__(rect, name) + self.colors = list(colors) + self.get_index = get_index + self.set_index = set_index + self.swatch_size = swatch_size + self.swatch_gap = swatch_gap + + def swatches(self) -> list[tuple[int | None, pygame.Rect]]: + """Alle Farbfelder mit ihrem Index (zuerst das Zufallsfeld).""" + indices: list[int | None] = [None, *range(len(self.colors))] + pitch = self.swatch_size + self.swatch_gap + return [ + ( + index, + pygame.Rect( + self.rect.x + i * pitch, self.rect.y, self.swatch_size, self.swatch_size + ), + ) + for i, index in enumerate(indices) + ] + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Ein Klick auf ein Farbfeld wählt es aus.""" + super().handle_event(event, mouse_pos) + for index, rect in self.swatches(): + if _is_left_press(event, rect): + self.set_index(index) + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet die Felder; das gewählte bekommt einen hellen Ring.""" + theme = style.theme + selected = self.get_index() + for index, rect in self.swatches(): + if index is None: + # Zufallsfeld: vier Palettenfarben als Viertel. + half = rect.width // 2 + quarters = self.colors[:: max(1, len(self.colors) // 4)][:4] + for k, color in enumerate(quarters): + quarter = pygame.Rect( + rect.x + (k % 2) * half, rect.y + (k // 2) * half, half, half + ) + pygame.draw.rect(surface, color, quarter) + else: + pygame.draw.rect(surface, self.colors[index], rect) + if index == selected: + gap = style.ui.selection_ring_gap + ring = rect.inflate(2 * gap, 2 * gap) + pygame.draw.rect(surface, theme.highlight, ring, style.ui.selection_ring_width) + + +class SpawnTile(Widget): + """Vorschau-Kachel des nächsten Objekts; aus ihr zieht man Objekte in die Welt. + + Args: + rect: Position und Größe. + get_settings: Liefert die aktuellen Werkzeug-Einstellungen. + get_color: Liefert die Farbe des nächsten Objekts. + on_drag_start: Wird beim Drücken auf die Kachel aufgerufen. + hint: Hinweistext unter der Vorschau. + name: Name des Widgets. + """ + + def __init__( + self, + rect: pygame.Rect, + get_settings: Callable[[], ToolSettings], + get_color: Callable[[], Color], + on_drag_start: Callable[[], None], + *, + hint: str, + name: str = "", + ) -> None: + super().__init__(rect, name) + self.get_settings = get_settings + self.get_color = get_color + self.on_drag_start = on_drag_start + self.hint = hint + + def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool: + """Drücken auf die Kachel beginnt das Herausziehen eines neuen Objekts.""" + super().handle_event(event, mouse_pos) + if _is_left_press(event, self.rect): + self.on_drag_start() + return True + return False + + def draw(self, surface: pygame.Surface, style: UiStyle) -> None: + """Zeichnet Kachel, maßstäblich verkleinerte Vorschau und Hinweis.""" + theme = style.theme + ui = style.ui + background = theme.panel_hover if self.hovered else theme.panel + pygame.draw.rect(surface, background, self.rect, border_radius=ui.corner_radius) + hint = style.render_text(self.hint, style.small_font, theme.text_dim) + hint_rect = hint.get_rect(midbottom=(self.rect.centerx, self.rect.bottom - ui.row_gap)) + surface.blit(hint, hint_rect) + + settings = self.get_settings() + half_width, half_height = settings.half_extents() + area_height = hint_rect.top - self.rect.top + available = min(self.rect.width, area_height) * ui.preview_scale_limit / 2 + scale = min(1.0, available / max(half_width, half_height)) + center = (self.rect.centerx, self.rect.top + area_height / 2) + color = self.get_color() + outline = shade(color, theme.outline_shade) + if settings.shape is ShapeKind.CIRCLE: + draw_circle( + surface, color, outline, center, half_width * scale, outline_width=ui.outline_width + ) + else: + draw_box( + surface, + color, + outline, + center, + (half_width * scale, half_height * scale), + outline_width=ui.outline_width, + ) diff --git a/src/sandbox/viewport.py b/src/sandbox/viewport.py new file mode 100644 index 0000000..d2268f5 --- /dev/null +++ b/src/sandbox/viewport.py @@ -0,0 +1,44 @@ +"""Abbildung zwischen Bildschirm- und Weltkoordinaten. + +Die Welt liegt rechts neben der Sidebar. Weil beide Koordinatensysteme in Pixeln rechnen +und die y-Achse gleich ausrichten, besteht die Abbildung nur aus einer Verschiebung. +""" + +from __future__ import annotations + +from sandbox.physics import Vector2 + + +class Viewport: + """Rechteckiger Bildschirmbereich, in dem die Welt gezeichnet wird. + + Args: + left: Linker Rand auf dem Bildschirm in px. + top: Oberer Rand auf dem Bildschirm in px. + width: Breite in px (= Breite der Welt). + height: Höhe in px (= Höhe der Welt). + """ + + def __init__(self, left: int, top: int, width: int, height: int) -> None: + self.left = left + self.top = top + self.width = width + self.height = height + + @property + def rect(self) -> tuple[int, int, int, int]: + """Bereich als ``(left, top, width, height)``.""" + return (self.left, self.top, self.width, self.height) + + def contains(self, screen_pos: tuple[float, float]) -> bool: + """True, wenn ein Bildschirmpunkt in der Welt liegt.""" + x, y = screen_pos + return self.left <= x < self.left + self.width and self.top <= y < self.top + self.height + + def to_world(self, screen_pos: tuple[float, float]) -> Vector2: + """Bildschirmpunkt → Weltpunkt.""" + return Vector2(screen_pos[0] - self.left, screen_pos[1] - self.top) + + def to_screen(self, point: Vector2) -> tuple[float, float]: + """Weltpunkt → Bildschirmpunkt.""" + return (point.x + self.left, point.y + self.top) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e270519 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Testsuite der Physik-Sandbox.""" diff --git a/tests/app/__init__.py b/tests/app/__init__.py new file mode 100644 index 0000000..7e0f919 --- /dev/null +++ b/tests/app/__init__.py @@ -0,0 +1 @@ +"""Headless-Tests für Oberfläche, Eingaben und Hauptschleife (SDL-Dummy-Treiber).""" diff --git a/tests/app/conftest.py b/tests/app/conftest.py new file mode 100644 index 0000000..541c354 --- /dev/null +++ b/tests/app/conftest.py @@ -0,0 +1,18 @@ +"""Fixture für die headless bediente Anwendung.""" + +import random +from collections.abc import Iterator + +import pygame +import pytest + +from sandbox.main import App + + +@pytest.fixture +def app() -> Iterator[App]: + """Frische Anwendung mit reproduzierbaren Zufallsfarben.""" + application = App(rng=random.Random(0)) + pygame.event.clear() + yield application + application.close() diff --git a/tests/app/driver.py b/tests/app/driver.py new file mode 100644 index 0000000..1906e6a --- /dev/null +++ b/tests/app/driver.py @@ -0,0 +1,68 @@ +"""Hilfsfunktionen, um die Anwendung headless per Event zu bedienen. + +Die Tests posten echte pygame-Events in die Event-Queue und rufen dann +:meth:`sandbox.main.App.frame` auf – genau wie die Hauptschleife. So wird die komplette +Kette Event → Sidebar/InputHandler → Werkzeuge → Physik → Zeichnen durchlaufen. +""" + +import pygame + +from sandbox.main import App +from sandbox.physics import Vector2 + +#: Dauer eines Test-Frames (60 Bilder/s → je zwei Physikschritte). +FRAME = 1 / 60 + + +def screen_pos(app: App, x: float, y: float) -> tuple[int, int]: + """Bildschirmposition eines Weltpunkts (auf ganze Pixel gerundet).""" + sx, sy = app.viewport.to_screen(Vector2(x, y)) + return (round(sx), round(sy)) + + +def frames(app: App, count: int = 1) -> None: + """Lässt die Anwendung einige Frames laufen.""" + for _ in range(count): + app.frame(FRAME) + + +def post(event_type: int, **attributes: object) -> None: + """Legt ein Event in die pygame-Queue.""" + pygame.event.post(pygame.event.Event(event_type, **attributes)) + + +def press(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None: + """Maustaste an ``pos`` drücken (ein Frame).""" + post(pygame.MOUSEBUTTONDOWN, pos=pos, button=button) + frames(app) + + +def move(app: App, pos: tuple[int, int]) -> None: + """Maus nach ``pos`` bewegen (ein Frame).""" + post(pygame.MOUSEMOTION, pos=pos, rel=(0, 0), buttons=(1, 0, 0)) + frames(app) + + +def release(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None: + """Maustaste an ``pos`` loslassen (ein Frame).""" + post(pygame.MOUSEBUTTONUP, pos=pos, button=button) + frames(app) + + +def click(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None: + """Klick (Drücken und Loslassen) an ``pos``.""" + press(app, pos, button) + release(app, pos, button) + + +def key(app: App, name: str) -> None: + """Taste drücken (Name wie in ``pygame.key.key_code``).""" + post(pygame.KEYDOWN, key=pygame.key.key_code(name), mod=0, unicode="", scancode=0) + frames(app) + + +def wheel(app: App, pos: tuple[int, int], steps: int) -> None: + """Mausrad über ``pos`` drehen.""" + move(app, pos) + post(pygame.MOUSEWHEEL, x=0, y=steps, flipped=False) + frames(app) diff --git a/tests/app/test_app_integration.py b/tests/app/test_app_integration.py new file mode 100644 index 0000000..0a615d9 --- /dev/null +++ b/tests/app/test_app_integration.py @@ -0,0 +1,332 @@ +"""Integrationstests: Die Anwendung wird headless über echte pygame-Events bedient.""" + +from dataclasses import replace +from pathlib import Path + +import pygame +import pytest + +from sandbox.config import APP, INTERACTION, PHYSICS +from sandbox.interaction import ShapeKind +from sandbox.main import App, main +from sandbox.physics import Vector2 +from sandbox.ui.widgets import ColorPicker, SegmentedControl, Slider +from tests.app.driver import click, frames, key, move, post, press, release, screen_pos, wheel + +# --------------------------------------------------------------------- Erstellen + + +def test_click_into_the_world_creates_a_body(app: App) -> None: + click(app, screen_pos(app, 400, 300)) + (body,) = app.world.bodies + assert body.position.is_close(Vector2(400, 300), tolerance=1.0) + assert body.shape.radius == app.tools.size + assert not body.held + + +def test_created_body_falls_and_lands_on_the_floor(app: App) -> None: + click(app, screen_pos(app, 400, 300)) + frames(app, 240) # Elastizität 0,5: nach ≈ 2,6 s sind alle Rücksprünge abgeklungen + (body,) = app.world.bodies + floor = app.world.height - app.tools.size + assert body.position.y == pytest.approx(floor, abs=PHYSICS.penetration_slop + 1e-3) + + +def test_sidebar_settings_apply_to_new_bodies(app: App) -> None: + shape = app.sidebar.widget("shape") + colors = app.sidebar.widget("color") + assert isinstance(shape, SegmentedControl) + assert isinstance(colors, ColorPicker) + click(app, shape.segment_rects()[1].center) # "Rechteck" + swatch_index, swatch = colors.swatches()[3] + click(app, swatch.center) + click(app, screen_pos(app, 400, 300)) + (body,) = app.world.bodies + assert app.tools.shape is ShapeKind.BOX + assert body.shape.half_extents() == pytest.approx(app.tools.half_extents()) + assert body.user_data.color == app.config.theme.body_palette[swatch_index] + + +def test_drag_from_the_sidebar_creates_a_body_when_entering_the_world(app: App) -> None: + tile = app.sidebar.widget("spawn_tile").rect + press(app, tile.center) + move(app, (tile.right - 5, tile.centery)) # noch über der Sidebar + assert app.world.bodies == [] + target = screen_pos(app, 200, 250) + move(app, target) + (body,) = app.world.bodies + assert body.held + release(app, target) + assert not body.held + + +def test_drag_from_the_sidebar_is_cancelled_when_released_over_the_sidebar(app: App) -> None: + tile = app.sidebar.widget("spawn_tile").rect + press(app, tile.center) + move(app, (tile.centerx, tile.top - 20)) + release(app, (tile.centerx, tile.top - 20)) + frames(app, 3) + assert app.world.bodies == [] + assert not app.input.spawn_drag_active + + +def test_body_limit_is_respected(app: App) -> None: + app.close() + limited = App(replace(APP, interaction=replace(INTERACTION, max_bodies=2))) + try: + for x in (200, 400, 600): + click(limited, screen_pos(limited, x, 300)) + assert len(limited.world.bodies) == 2 + assert "(voll)" in limited.status_lines()[0] + finally: + limited.close() + + +# --------------------------------------------------------------------- Greifen, Ziehen, Werfen + + +def _resting_body(app: App, x: float) -> tuple[int, int]: + """Erzeugt einen Körper, lässt ihn landen und liefert seine Bildschirmposition.""" + click(app, screen_pos(app, x, 600)) + frames(app, 90) + body = app.world.bodies[-1] + return screen_pos(app, body.position.x, body.position.y) + + +def test_grabbed_body_follows_the_mouse(app: App) -> None: + start = _resting_body(app, 300) + press(app, start) + body = app.drag.body + assert body is not None + for step in range(1, 21): + move(app, (start[0] + 5 * step, start[1] - 10 * step)) + frames(app, 10) + expected = app.viewport.to_world((start[0] + 100, start[1] - 200)) + assert body.position.is_close(expected, tolerance=1.0) + release(app, (start[0] + 100, start[1] - 200)) + + +def test_release_throws_the_body_with_the_mouse_velocity(app: App) -> None: + start = _resting_body(app, 300) + press(app, start) + body = app.drag.body + assert body is not None + x, y = start + for _ in range(20): # 10 px pro Frame bei 60 Bildern/s = 600 px/s nach rechts + x += 10 + move(app, (x, y - 150)) + release(app, (x, y - 150)) + assert body.velocity.x == pytest.approx(600, rel=0.05) + frames(app, 10) + assert body.position.x > app.viewport.to_world((x, y)).x # fliegt weiter + + +def test_holding_still_before_release_does_not_throw(app: App) -> None: + start = _resting_body(app, 300) + press(app, start) + body = app.drag.body + assert body is not None + target = (start[0] + 200, start[1] - 150) + for step in range(1, 11): + move(app, (start[0] + 20 * step, start[1] - 15 * step)) + frames(app, 30) # eine halbe Sekunde stillhalten + release(app, target) + assert abs(body.velocity.x) < 1 + + +def test_dragged_body_pushes_other_bodies(app: App) -> None: + obstacle_pos = _resting_body(app, 300) + obstacle = app.world.bodies[-1] + start = _resting_body(app, 600) + press(app, start) + held = app.drag.body + assert held is not None and held is not obstacle + for step in range(1, 31): # nach links durch das Hindernis hindurch ziehen + move(app, (start[0] - 15 * step, start[1])) + assert obstacle.position.x < app.viewport.to_world(obstacle_pos).x - 50 + overlap = ( + held.shape.radius + obstacle.shape.radius - held.position.distance_to(obstacle.position) + ) + assert overlap < PHYSICS.max_position_correction + release(app, (start[0] - 450, start[1])) + + +def test_dragged_body_cannot_leave_the_world(app: App) -> None: + start = _resting_body(app, 300) + press(app, start) + body = app.drag.body + assert body is not None + move(app, (5, 5)) # über die Sidebar hinaus + frames(app, 30) + radius = body.shape.radius + assert body.position.x >= radius - PHYSICS.max_wall_penetration + assert body.position.y >= radius - PHYSICS.max_wall_penetration + release(app, (5, 5)) + + +def test_paused_drag_places_the_body_without_throwing(app: App) -> None: + start = _resting_body(app, 300) + key(app, "space") + assert app.paused + # Versatz zwischen Griffpunkt (ganze Pixel) und Schwerpunkt, gemessen vor dem Greifen. + grip_offset = app.world.bodies[-1].position - app.viewport.to_world(start) + press(app, start) + body = app.drag.body + assert body is app.world.bodies[-1] + target = (start[0] + 120, start[1] - 200) + move(app, target) + expected = app.viewport.to_world(target) + grip_offset + assert body.position.is_close(expected, tolerance=1e-6) + release(app, target) + assert body.velocity == Vector2(0, 0) + frames(app, 10) + assert body.position.is_close(expected, tolerance=1e-6) # bleibt: Simulation pausiert + + +# --------------------------------------------------------------------- Löschen + + +def test_right_click_deletes_a_body(app: App) -> None: + pos = _resting_body(app, 300) + click(app, pos, pygame.BUTTON_RIGHT) + assert app.world.bodies == [] + + +def test_delete_key_removes_the_body_under_the_mouse(app: App) -> None: + pos = _resting_body(app, 300) + move(app, pos) + assert app.input.hovered_body is app.world.bodies[0] + key(app, "delete") + assert app.world.bodies == [] + + +def test_right_click_on_the_held_body_releases_and_deletes_it(app: App) -> None: + pos = _resting_body(app, 300) + press(app, pos) + press(app, pos, pygame.BUTTON_RIGHT) + assert app.world.bodies == [] + assert app.drag.body is None + release(app, pos) + + +def test_clear_button_removes_everything(app: App) -> None: + for x in (200, 400, 600): + click(app, screen_pos(app, x, 300)) + click(app, app.sidebar.widget("clear").rect.center) + assert app.world.bodies == [] + + +# --------------------------------------------------------------------- Sidebar und Tastatur + + +def test_pause_button_freezes_and_resumes_the_simulation(app: App) -> None: + click(app, screen_pos(app, 400, 100)) + body = app.world.bodies[0] + pause = app.sidebar.widget("pause").rect.center + click(app, pause) + assert app.paused + frozen = body.position + frames(app, 30) + assert body.position == frozen + click(app, pause) + frames(app, 10) + assert body.position.y > frozen.y + + +def test_gravity_button_toggles_gravity(app: App) -> None: + click(app, app.sidebar.widget("gravity").rect.center) + assert not app.world.gravity_enabled + click(app, screen_pos(app, 400, 100)) + frames(app, 30) + assert app.world.bodies[0].position.is_close(Vector2(400, 100), tolerance=1e-6) + + +def test_button_only_triggers_when_released_over_it(app: App) -> None: + button = app.sidebar.widget("pause").rect + press(app, button.center) + release(app, (button.centerx, button.bottom + 40)) + assert not app.paused + + +def test_size_slider_can_be_clicked_dragged_and_scrolled(app: App) -> None: + slider = app.sidebar.widget("size") + assert isinstance(slider, Slider) + press(app, (slider.rect.right - 1, slider.track_y)) + assert app.tools.size == INTERACTION.max_size + move(app, (slider.rect.left - 50, slider.track_y)) # Ziehen darf die Sidebar verlassen + assert app.tools.size == INTERACTION.min_size + release(app, (slider.rect.left - 50, slider.track_y)) + wheel(app, slider.rect.center, 3) + assert app.tools.size == INTERACTION.min_size + 3 * INTERACTION.size_step + + +def test_aspect_slider_is_disabled_for_circles(app: App) -> None: + slider = app.sidebar.widget("aspect_ratio") + assert isinstance(slider, Slider) + before = app.tools.aspect_ratio + click(app, (slider.rect.right - 1, slider.track_y)) + assert app.tools.aspect_ratio == before + key(app, "2") + click(app, (slider.rect.right - 1, slider.track_y)) + assert app.tools.aspect_ratio == INTERACTION.max_aspect_ratio + + +def test_mouse_wheel_over_the_world_changes_the_size(app: App) -> None: + before = app.tools.size + wheel(app, screen_pos(app, 400, 300), 2) + assert app.tools.size == before + 2 * INTERACTION.size_wheel_step + wheel(app, screen_pos(app, 400, 300), -1000) + assert app.tools.size == INTERACTION.min_size + + +def test_keyboard_shortcuts(app: App) -> None: + key(app, "space") + assert app.paused + key(app, "space") + assert not app.paused + key(app, "g") + assert not app.world.gravity_enabled + key(app, "2") + assert app.tools.shape is ShapeKind.BOX + key(app, "1") + assert app.tools.shape is ShapeKind.CIRCLE + key(app, "f1") + assert app.debug + click(app, screen_pos(app, 400, 300)) + key(app, "c") + assert app.world.bodies == [] + key(app, "x") # nicht belegt: keine Wirkung + key(app, "escape") + assert not app.running + + +def test_closing_the_window_stops_the_main_loop(app: App) -> None: + post(pygame.QUIT) + frames(app) + assert not app.running + + +# --------------------------------------------------------------------- Darstellung und Start + + +def test_debug_view_and_demo_scene_render_without_errors(app: App) -> None: + app.load_demo_scene() + key(app, "f1") + frames(app, 30) + lines = app._debug_lines() + assert lines[0].startswith("Körper: 26") + move(app, screen_pos(app, 100, 100)) # Vorschau über leerer Welt + assert app.input.spawn_preview() is not None + + +def test_screenshot_is_written(app: App, tmp_path: Path) -> None: + path = tmp_path / "shot.png" + frames(app) + app.save_screenshot(path) + assert path.stat().st_size > 0 + + +def test_command_line_screenshot_mode(tmp_path: Path) -> None: + path = tmp_path / "cli.png" + assert main(["--demo", "--screenshot", str(path), "--seconds", "0.2"]) == 0 + assert path.exists() diff --git a/tests/app/test_entry_points_and_edge_cases.py b/tests/app/test_entry_points_and_edge_cases.py new file mode 100644 index 0000000..c9820d2 --- /dev/null +++ b/tests/app/test_entry_points_and_edge_cases.py @@ -0,0 +1,111 @@ +"""Einstiegspunkte (``python -m sandbox``, CLI) und Randfälle der Eingabeverarbeitung.""" + +import runpy +import sys +from dataclasses import replace +from pathlib import Path + +import pygame +import pytest + +from sandbox import main as main_module +from sandbox.config import APP, INTERACTION, Theme, UiConfig +from sandbox.main import App, FrameStats, main +from sandbox.ui.style import UiStyle, _load_font +from tests.app.driver import click, frames, key, move, post, press, release, screen_pos + + +def test_python_dash_m_starts_the_app(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + screenshot = tmp_path / "module.png" + monkeypatch.setattr( + sys, "argv", ["sandbox", "--screenshot", str(screenshot), "--seconds", "0.1"] + ) + with pytest.raises(SystemExit) as exit_info: + runpy.run_module("sandbox", run_name="__main__") + assert exit_info.value.code == 0 + assert screenshot.exists() + + +def test_main_without_options_runs_the_interactive_loop(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[float | None] = [] + monkeypatch.setattr( + main_module.App, "run", lambda self, max_seconds=None: calls.append(max_seconds) + ) + assert main([]) == 0 + assert calls == [None] + + +def test_main_loop_ends_when_the_window_is_closed(app: App) -> None: + post(pygame.QUIT) + app.run() # kehrt nach dem ersten Frame zurück + assert not app.running + + +def test_frame_stats_are_smoothed_and_ignore_empty_frames() -> None: + stats = FrameStats(smoothing=0.5) + stats.update(1 / 50, 0.002) + assert stats.fps == pytest.approx(50) + stats.update(1 / 100, 0.004) + assert stats.fps == pytest.approx(75) + stats.update(0.0, 0.0) # z. B. allererster Frame: keine Division durch 0 + assert stats.fps == pytest.approx(75) + assert stats.physics_ms > 0 + + +def test_clicks_outside_the_world_without_sidebar_are_ignored(app: App) -> None: + app.input.sidebar = None # InputHandler auch ohne Sidebar nutzbar + click(app, (10, 10)) # links neben der Welt + assert app.world.bodies == [] + click(app, screen_pos(app, 400, 300)) + assert len(app.world.bodies) == 1 + + +def test_middle_click_and_delete_without_target_do_nothing(app: App) -> None: + click(app, screen_pos(app, 400, 300), pygame.BUTTON_MIDDLE) + assert app.world.bodies == [] + move(app, screen_pos(app, 600, 300)) + key(app, "delete") # nichts unter der Maus + click(app, screen_pos(app, 600, 300), pygame.BUTTON_RIGHT) + assert app.world.bodies == [] + + +def test_spawn_drag_is_ignored_when_the_world_is_full(app: App) -> None: + app.close() + full = App(replace(APP, interaction=replace(INTERACTION, max_bodies=1))) + try: + click(full, screen_pos(full, 400, 300)) + tile = full.sidebar.widget("spawn_tile").rect + press(full, tile.center) + move(full, screen_pos(full, 200, 200)) + assert len(full.world.bodies) == 1 + assert full.drag.body is None + assert full.input.spawn_preview() is None # voll: keine Vorschau mehr + release(full, screen_pos(full, 200, 200)) + frames(full) + finally: + full.close() + + +def test_unknown_widget_names_raise(app: App) -> None: + with pytest.raises(KeyError): + app.sidebar.widget("gibt-es-nicht") + + +def test_missing_system_fonts_fall_back_to_the_builtin_font() -> None: + pygame.font.init() + try: + font = _load_font(("keine-solche-schrift-xyz",), 14, bold=True) + assert font.bold + assert font.size("Test")[0] > 0 + finally: + pygame.font.quit() + + +def test_style_initializes_the_font_module_itself() -> None: + pygame.font.quit() + style = UiStyle(Theme(), UiConfig()) + try: + assert pygame.font.get_init() + assert style.render_text("x", style.label_font, (0, 0, 0)).get_width() > 0 + finally: + pygame.font.quit() diff --git a/tests/app/test_smoke.py b/tests/app/test_smoke.py new file mode 100644 index 0000000..c1b1e31 --- /dev/null +++ b/tests/app/test_smoke.py @@ -0,0 +1,77 @@ +"""Smoke-Test: Die echte Hauptschleife läuft einige Sekunden headless mit Zufallseingaben. + +Ein simulierter, zufällig klickender "Affe" bedient dabei alles, was ein Mensch auch +bedienen könnte: Klicks in Welt und Sidebar, Ziehen und Werfen, Rechtsklicks, Mausrad +und Tastenkürzel. Der Test gilt als bestanden, wenn keine Exception auftritt und die +Simulation danach in einem gültigen Zustand ist. +""" + +import random + +import pygame +import pytest + +from sandbox.config import PHYSICS +from sandbox.main import App + +SMOKE_SECONDS = 3.0 +SAFE_KEYS = ("space", "g", "c", "delete", "1", "2", "f1") # alles außer "Beenden" + + +class MonkeyApp(App): + """Die echte Anwendung, die sich vor jedem Frame selbst zufällige Events schickt.""" + + def __init__(self, seed: int) -> None: + super().__init__(rng=random.Random(seed)) + self.monkey = random.Random(seed) + self.frame_count = 0 + + def frame(self, frame_time: float) -> None: + self._post_random_events() + super().frame(frame_time) + self.frame_count += 1 + + def _random_position(self) -> tuple[int, int]: + return ( + self.monkey.randrange(self.config.window.width), + self.monkey.randrange(self.config.window.height), + ) + + def _post_random_events(self) -> None: + roll = self.monkey.random() + position = self._random_position() + if roll < 0.25: + button = self.monkey.choice([pygame.BUTTON_LEFT, pygame.BUTTON_RIGHT]) + pygame.event.post( + pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=position, button=button) + ) + elif roll < 0.45: + pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONUP, pos=position, button=1)) + elif roll < 0.85: + pygame.event.post( + pygame.event.Event(pygame.MOUSEMOTION, pos=position, rel=(0, 0), buttons=(1, 0, 0)) + ) + elif roll < 0.93: + pygame.event.post( + pygame.event.Event(pygame.MOUSEWHEEL, x=0, y=self.monkey.choice([-1, 1])) + ) + else: + key = pygame.key.key_code(self.monkey.choice(SAFE_KEYS)) + pygame.event.post( + pygame.event.Event(pygame.KEYDOWN, key=key, mod=0, unicode="", scancode=0) + ) + + +@pytest.mark.slow +def test_main_loop_survives_seconds_of_random_input() -> None: + app = MonkeyApp(seed=7) + try: + app.load_demo_scene() + app.run(max_seconds=SMOKE_SECONDS) + assert app.running + assert app.frame_count > 0 + for body in app.world.bodies: + assert body.position.is_finite() + assert body.velocity.length() <= PHYSICS.max_speed + 1e-6 + finally: + app.close() diff --git a/tests/app/test_widgets.py b/tests/app/test_widgets.py new file mode 100644 index 0000000..73c141b --- /dev/null +++ b/tests/app/test_widgets.py @@ -0,0 +1,193 @@ +"""Tests einzelner Widgets (headless) sowie kleiner Hilfsbausteine der Oberfläche.""" + +import math +from collections.abc import Iterator + +import pygame +import pytest + +from sandbox.config import INTERACTION, Theme, UiConfig +from sandbox.interaction import ShapeKind, ToolSettings +from sandbox.renderer import interpolate_angle +from sandbox.ui.style import UiStyle, shade +from sandbox.ui.widgets import ( + Button, + ColorPicker, + Label, + LabelKind, + SegmentedControl, + Slider, + SpawnTile, + Widget, +) +from sandbox.viewport import Viewport + + +@pytest.fixture +def style() -> Iterator[UiStyle]: + pygame.init() + yield UiStyle(Theme(), UiConfig()) + pygame.quit() + + +def _event(event_type: int, **attributes: object) -> pygame.event.Event: + return pygame.event.Event(event_type, **attributes) + + +def _press(pos: tuple[int, int]) -> pygame.event.Event: + return _event(pygame.MOUSEBUTTONDOWN, pos=pos, button=pygame.BUTTON_LEFT) + + +def _release(pos: tuple[int, int]) -> pygame.event.Event: + return _event(pygame.MOUSEBUTTONUP, pos=pos, button=pygame.BUTTON_LEFT) + + +def _slider(value: list[float], enabled: bool = True) -> Slider: + return Slider( + pygame.Rect(0, 0, 214, 38), + "Test", + minimum=10, + maximum=110, + step=5, + get_value=lambda: value[0], + set_value=lambda new: value.__setitem__(0, new), + value_format=str, + knob_radius=7, + is_enabled=lambda: enabled, + ) + + +def test_slider_maps_positions_to_snapped_values() -> None: + slider = _slider([10.0]) + assert slider.value_at(slider.track_left) == 10 + assert slider.value_at(slider.track_left + slider.track_width) == 110 + assert slider.value_at(slider.track_left + slider.track_width / 2) == 60 + assert slider.value_at(-1000) == 10 # außerhalb wird begrenzt + assert slider.snap(23) == 25 + assert slider.x_of(60) == pytest.approx(slider.track_left + slider.track_width / 2) + + +def test_slider_drag_changes_the_value_until_released() -> None: + value = [10.0] + slider = _slider(value) + assert slider.handle_event(_press((slider.track_left, slider.track_y)), (0, 0)) + motion = _event(pygame.MOUSEMOTION, pos=(500, 20)) + assert slider.handle_event(motion, (500, 20)) + assert value[0] == 110 + assert slider.handle_event(_release((500, 20)), (500, 20)) + assert not slider.dragging + assert not slider.handle_event(motion, (500, 20)) # nach dem Loslassen: kein Ziehen mehr + + +def test_disabled_slider_swallows_clicks_but_does_nothing() -> None: + value = [10.0] + slider = _slider(value, enabled=False) + assert slider.handle_event(_press((100, 30)), (100, 30)) + assert value[0] == 10 + assert not slider.handle_event(_event(pygame.MOUSEWHEEL, x=0, y=1), (100, 30)) + + +def test_slider_rejects_an_empty_range() -> None: + with pytest.raises(ValueError, match="maximum > minimum"): + Slider( + pygame.Rect(0, 0, 100, 38), + "x", + minimum=1, + maximum=1, + step=1, + get_value=lambda: 1.0, + set_value=lambda _: None, + value_format=str, + knob_radius=7, + ) + + +def test_segmented_control_and_color_picker_select_on_click() -> None: + selected: list[object] = [ShapeKind.CIRCLE, None] + control = SegmentedControl( + pygame.Rect(0, 0, 200, 30), + [(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")], + lambda: selected[0], + lambda value: selected.__setitem__(0, value), + ) + assert control.handle_event(_press(control.segment_rects()[1].center), (0, 0)) + assert selected[0] is ShapeKind.BOX + assert not control.handle_event(_press((500, 500)), (500, 500)) + + picker = ColorPicker( + pygame.Rect(0, 40, 250, 22), + Theme().body_palette, + lambda: selected[1], # type: ignore[return-value] + lambda index: selected.__setitem__(1, index), + swatch_size=22, + swatch_gap=3, + ) + swatches = picker.swatches() + assert swatches[0][0] is None # erstes Feld = Zufallsfarbe + assert picker.handle_event(_press(swatches[2][1].center), (0, 0)) + assert selected[1] == 1 + assert picker.handle_event(_press(swatches[0][1].center), (0, 0)) + assert selected[1] is None + + +def test_all_widgets_draw_in_every_state(style: UiStyle) -> None: + surface = pygame.Surface((300, 600)) + clicks: list[str] = [] + tools = ToolSettings.from_config(INTERACTION) + widgets: list[Widget] = [ + Label(pygame.Rect(0, 0, 200, 20), "Titel", LabelKind.TITLE), + Label(pygame.Rect(0, 20, 200, 20), lambda: "dynamisch", LabelKind.HINT), + Button( + pygame.Rect(0, 40, 100, 30), "Aktiv", lambda: clicks.append("a"), is_active=lambda: True + ), + Button(pygame.Rect(0, 80, 100, 30), "Gefahr", lambda: None, danger=True), + _slider([60.0]), + _slider([60.0], enabled=False), + SpawnTile( + pygame.Rect(0, 300, 228, 92), + lambda: tools, + lambda: (200, 100, 50), + lambda: clicks.append("spawn"), + hint="Hinweis", + ), + ] + for widget in widgets: + widget.handle_event(_event(pygame.MOUSEMOTION, pos=widget.rect.center), widget.rect.center) + widget.draw(surface, style) + tools.shape = ShapeKind.BOX + widgets[-1].draw(surface, style) + assert widgets[-1].handle_event(_press(widgets[-1].rect.center), (0, 0)) + assert clicks == ["spawn"] + + +def test_base_widget_cannot_be_drawn(style: UiStyle) -> None: + with pytest.raises(NotImplementedError): + Widget(pygame.Rect(0, 0, 1, 1)).draw(pygame.Surface((1, 1)), style) + + +def test_text_cache_is_bounded(style: UiStyle) -> None: + for number in range(style.ui.text_cache_size + 10): + style.render_text(str(number), style.small_font, (255, 255, 255)) + assert len(style._text_cache) <= style.ui.text_cache_size + + +def test_shade_clamps_to_valid_colors() -> None: + assert shade((100, 200, 250), 0.5) == (50, 100, 125) + assert shade((100, 200, 250), 2.0) == (200, 255, 255) + + +def test_viewport_maps_between_screen_and_world() -> None: + viewport = Viewport(260, 0, 1020, 720) + point = viewport.to_world((300, 50)) + assert (point.x, point.y) == (40, 50) + assert viewport.to_screen(point) == (300, 50) + assert viewport.contains((260, 0)) + assert not viewport.contains((259, 10)) + assert not viewport.contains((1280, 10)) + + +def test_angle_interpolation_takes_the_short_way_around() -> None: + assert interpolate_angle(0.0, 1.0, 0.5) == pytest.approx(0.5) + # Von knapp unter +π nach knapp über −π ist nur ein kleiner Schritt. + halfway = interpolate_angle(math.pi - 0.1, -math.pi + 0.1, 0.5) + assert math.cos(halfway) == pytest.approx(-1.0) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4c0dce4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Gemeinsame pytest-Konfiguration. + +Die Umgebungsvariablen müssen gesetzt sein, *bevor* irgendein Test pygame importiert: +Mit dem Dummy-Treiber öffnet SDL kein echtes Fenster und braucht keine Soundkarte – so +laufen UI- und Integrationstests auch headless (z. B. in CI). +""" + +import os + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") +os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1") diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..b5a9acf --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,81 @@ +"""Hilfsfunktionen für die Physik-Tests (Szenen aufbauen, simulieren, Erhaltungsgrößen).""" + +from __future__ import annotations + +from dataclasses import replace + +from sandbox.config import PHYSICS, TIME_STEP +from sandbox.physics import Body, Material, Vector2, World + +WORLD_WIDTH = 1000.0 +WORLD_HEIGHT = 700.0 + + +def make_world( + width: float = WORLD_WIDTH, height: float = WORLD_HEIGHT, **overrides: object +) -> World: + """Welt mit Standardkonfiguration; einzelne Parameter lassen sich überschreiben.""" + return World(width, height, replace(PHYSICS, **overrides)) + + +def material(density: float = 1.0, restitution: float = 0.5, friction: float = 0.5) -> Material: + """Material mit gut lesbaren Standardwerten.""" + return Material(density=density, restitution=restitution, friction=friction) + + +def add_circle( + world: World, + x: float, + y: float, + radius: float, + *, + velocity: tuple[float, float] = (0.0, 0.0), + angular_velocity: float = 0.0, + **material_values: float, +) -> Body: + """Fügt einen Kreis hinzu.""" + body = Body.circle( + Vector2(x, y), radius, material(**material_values), velocity=Vector2(*velocity) + ) + body.angular_velocity = angular_velocity + return world.add_body(body) + + +def add_box( + world: World, + x: float, + y: float, + half_width: float, + half_height: float, + *, + velocity: tuple[float, float] = (0.0, 0.0), + **material_values: float, +) -> Body: + """Fügt ein achsenparalleles Rechteck hinzu.""" + body = Body.box( + Vector2(x, y), + half_width, + half_height, + material(**material_values), + velocity=Vector2(*velocity), + ) + return world.add_body(body) + + +def simulate(world: World, seconds: float, dt: float = TIME_STEP) -> None: + """Führt so viele feste Schritte aus, wie in ``seconds`` passen.""" + for _ in range(round(seconds / dt)): + world.step(dt) + + +def total_momentum(bodies: list[Body]) -> Vector2: + """Summe der Impulse m·v.""" + total = Vector2() + for body in bodies: + total = total + body.momentum() + return total + + +def total_kinetic_energy(bodies: list[Body]) -> float: + """Summe der Bewegungsenergien (translatorisch + rotatorisch).""" + return sum(body.kinetic_energy() for body in bodies) diff --git a/tests/interaction/__init__.py b/tests/interaction/__init__.py new file mode 100644 index 0000000..4e29fd9 --- /dev/null +++ b/tests/interaction/__init__.py @@ -0,0 +1 @@ +"""Tests der pygame-freien Werkzeug-Logik.""" diff --git a/tests/interaction/test_demo.py b/tests/interaction/test_demo.py new file mode 100644 index 0000000..3ac3aac --- /dev/null +++ b/tests/interaction/test_demo.py @@ -0,0 +1,29 @@ +"""Tests der Beispielszene.""" + +import random + +from sandbox.config import INTERACTION, TIME_STEP, DemoSceneConfig, Theme +from sandbox.demo import build_demo_scene +from sandbox.interaction import BodyFactory, ToolSettings +from tests.helpers import make_world + + +def test_demo_scene_fits_into_the_world_and_settles() -> None: + world = make_world(1020, 720) + factory = BodyFactory(world, INTERACTION, Theme().body_palette, random.Random(0)) + scene = DemoSceneConfig() + build_demo_scene(factory, ToolSettings.from_config(INTERACTION), scene) + + pyramid = scene.pyramid_rows * (scene.pyramid_rows + 1) // 2 + assert len(world.bodies) == scene.tower_levels + pyramid + scene.ball_count + for body in world.bodies: + min_x, min_y, max_x, max_y = body.bounds() + assert min_x >= 0 and max_x <= world.width + assert min_y >= 0 and max_y <= world.height + + for _ in range(round(4 / TIME_STEP)): + world.step(TIME_STEP) + tower = world.bodies[: scene.tower_levels] + assert all(body.sleeping for body in tower) # der Turm steht ruhig + tops = [body.position.y for body in tower] + assert tops == sorted(tops, reverse=True) # und ist nicht eingestürzt diff --git a/tests/interaction/test_drag_and_tools.py b/tests/interaction/test_drag_and_tools.py new file mode 100644 index 0000000..51ac7b7 --- /dev/null +++ b/tests/interaction/test_drag_and_tools.py @@ -0,0 +1,226 @@ +"""Tests für Greifen/Ziehen/Werfen und das Erzeugen von Körpern (ohne pygame).""" + +import math +import random +from dataclasses import replace + +import pytest + +from sandbox.config import INTERACTION, MIN_BODY_SIZE, PHYSICS, TIME_STEP, Theme +from sandbox.interaction import BodyFactory, BodyStyle, DragController, ShapeKind, ToolSettings +from sandbox.physics import Box, Circle, Vector2 +from tests.helpers import add_box, add_circle, make_world + +PALETTE = Theme().body_palette + + +def _factory(**overrides: object) -> BodyFactory: + return BodyFactory(make_world(), replace(INTERACTION, **overrides), PALETTE, random.Random(1)) + + +def _settings(**overrides: object) -> ToolSettings: + return replace(ToolSettings.from_config(INTERACTION), **overrides) + + +# --------------------------------------------------------------------- Werkzeug-Einstellungen + + +def test_circle_half_extents_are_the_radius() -> None: + assert _settings(shape=ShapeKind.CIRCLE, size=30).half_extents() == (30, 30) + + +def test_box_aspect_ratio_changes_shape_but_not_area() -> None: + wide = _settings(shape=ShapeKind.BOX, size=30, aspect_ratio=2.0).half_extents() + square = _settings(shape=ShapeKind.BOX, size=30, aspect_ratio=1.0).half_extents() + assert wide[0] > wide[1] + assert square == pytest.approx((30, 30)) + assert wide[0] * wide[1] == pytest.approx(square[0] * square[1]) + + +def test_box_sides_never_drop_below_the_minimum_size() -> None: + thin = _settings(shape=ShapeKind.BOX, size=MIN_BODY_SIZE, aspect_ratio=2.0).half_extents() + assert min(thin) == pytest.approx(MIN_BODY_SIZE) + + +# --------------------------------------------------------------------- Körper erzeugen + + +def test_factory_creates_circles_and_boxes_with_the_selected_material() -> None: + factory = _factory() + circle = factory.create(_settings(size=20, density=2.0, restitution=0.9), Vector2(300, 300)) + assert circle is not None + assert isinstance(circle.shape, Circle) + assert circle.mass == pytest.approx(math.pi * 20**2 * 2.0) + assert circle.material.restitution == 0.9 + assert circle.material.friction == INTERACTION.friction + + box = factory.create(_settings(shape=ShapeKind.BOX, size=20), Vector2(500, 300)) + assert box is not None + assert isinstance(box.shape, Box) + assert factory.world.bodies == [circle, box] + + +def test_factory_moves_new_bodies_completely_into_the_world() -> None: + factory = _factory() + body = factory.create(_settings(size=30), Vector2(-100, 5000)) + assert body is not None + assert body.position == Vector2(30, factory.world.height - 30) + + +def test_factory_respects_the_body_limit() -> None: + factory = _factory(max_bodies=2) + settings = _settings() + assert factory.create(settings, Vector2(100, 100)) is not None + assert factory.create(settings, Vector2(200, 100)) is not None + assert factory.is_full + assert factory.create(settings, Vector2(300, 100)) is None + assert len(factory.world.bodies) == 2 + + +def test_preview_color_is_the_color_of_the_next_body() -> None: + factory = _factory() + settings = _settings(color_index=None) + for _ in range(10): + announced = factory.color_for(settings) + body = factory.create(settings, Vector2(300, 300)) + assert body is not None + assert body.user_data == BodyStyle(announced) + + +def test_selected_palette_color_is_used() -> None: + factory = _factory() + body = factory.create(_settings(color_index=3), Vector2(300, 300)) + assert body is not None + assert body.user_data == BodyStyle(PALETTE[3]) + + +# --------------------------------------------------------------------- Ziehen und Werfen + + +def test_grab_marks_the_body_as_held_and_keeps_the_grip_offset() -> None: + world = make_world() + body = add_circle(world, 300, 300, 20) + body.put_to_sleep() + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(310, 295), time=0.0) + assert drag.body is body + assert body.held + assert not body.sleeping + drag.move(Vector2(410, 295), time=0.1) + assert drag.target == Vector2(400, 300) # Griffversatz (−10, +5) bleibt erhalten + + +def test_target_is_clamped_so_the_body_fits_into_the_world() -> None: + world = make_world() + body = add_circle(world, 300, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(300, 300), time=0.0) + drag.move(Vector2(-500, -500), time=0.1) + assert drag.target == Vector2(20, 20) + + +def test_held_body_follows_the_pointer_through_the_physics() -> None: + world = make_world() + body = add_circle(world, 300, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(300, 300), time=0.0) + drag.move(Vector2(450, 250), time=0.0) + for _ in range(60): + drag.apply(TIME_STEP) + world.step(TIME_STEP) + assert body.position.is_close(Vector2(450, 250), tolerance=0.5) + + +def test_held_body_speed_is_limited() -> None: + world = make_world() + body = add_circle(world, 30, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(30, 300), time=0.0) + drag.move(Vector2(980, 300), time=0.0) + drag.apply(TIME_STEP) + assert body.velocity.length() == pytest.approx(PHYSICS.max_speed) + + +def test_held_body_still_collides_and_pushes_other_bodies() -> None: + world = make_world() + obstacle = add_box(world, 500, 680, 20, 20) + held = add_box(world, 300, 680, 20, 20) + drag = DragController(world, INTERACTION) + drag.grab(held, Vector2(300, 680), time=0.0) + drag.move(Vector2(700, 680), time=0.0) + for _ in range(120): + drag.apply(TIME_STEP) + world.step(TIME_STEP) + assert obstacle.position.x > 520 # wurde weggeschoben + assert obstacle.position.x - held.position.x >= 40 - PHYSICS.penetration_slop - 1e-3 + + +def test_held_body_pressed_against_an_obstacle_does_not_jitter() -> None: + """Ein gegen ein blockiertes Hindernis gedrückter, gehaltener Körper steht ruhig. + + Würde er abprallen, zöge ihn die Maus im nächsten Schritt wieder heran – er zitterte. + """ + world = make_world() + add_box(world, 1000 - 20, 680, 20, 20, restitution=1.0) # liegt an der rechten Wand + held = add_box(world, 700, 680, 20, 20, restitution=1.0) + drag = DragController(world, INTERACTION) + drag.grab(held, Vector2(700, 680), time=0.0) + drag.move(Vector2(990, 680), time=0.0) # Ziel liegt hinter dem Hindernis + positions = [] + for step in range(180): + drag.apply(TIME_STEP) + world.step(TIME_STEP) + if step >= 120: + positions.append(held.position.x) + assert max(positions) - min(positions) < 0.1 + # Wand → Hindernis → gehaltener Körper: Jeder der beiden Kontakte behält höchstens den + # Slop als Resttiefe, der Körper steht also bei x ≤ 1000 − 40 − 20 + 2·Slop. + assert max(positions) <= 1000 - 40 - 20 + 2 * PHYSICS.penetration_slop + 1e-3 + + +def test_release_throws_with_the_smoothed_pointer_velocity() -> None: + world = make_world(gravity=(0.0, 0.0)) + body = add_circle(world, 200, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(200, 300), time=0.0) + for frame in range(1, 31): + t = frame / 60 + drag.move(Vector2(200 + 600 * t, 300 - 150 * t), time=t) + assert drag.release(paused=False) is body + assert body.velocity.is_close(Vector2(600, -150), tolerance=5.0) + assert not body.held + assert drag.body is None + + +def test_throw_speed_is_limited() -> None: + world = make_world() + body = add_circle(world, 200, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(200, 300), time=0.0) + for frame in range(1, 11): + drag.move(Vector2(200 + 100 * frame, 300), time=frame / 120) # 12 000 px/s + drag.release(paused=False) + assert body.velocity.length() == pytest.approx(INTERACTION.max_throw_speed) + + +def test_release_while_paused_does_not_throw() -> None: + world = make_world() + body = add_circle(world, 200, 300, 20) + drag = DragController(world, INTERACTION) + drag.grab(body, Vector2(200, 300), time=0.0) + drag.move(Vector2(260, 300), time=0.05) + drag.place() + assert body.position == Vector2(260, 300) + drag.release(paused=True) + assert body.velocity == Vector2(0, 0) + + +def test_cancel_and_release_without_body_are_harmless() -> None: + world = make_world() + drag = DragController(world, INTERACTION) + drag.cancel() + drag.apply(TIME_STEP) + drag.place() + drag.move(Vector2(1, 1), time=0.0) + assert drag.release(paused=False) is None + assert drag.throw_velocity == Vector2(0, 0) diff --git a/tests/interaction/test_velocity_tracker.py b/tests/interaction/test_velocity_tracker.py new file mode 100644 index 0000000..3b5bbfd --- /dev/null +++ b/tests/interaction/test_velocity_tracker.py @@ -0,0 +1,75 @@ +"""Tests der geglätteten Wurfgeschwindigkeit.""" + +import random + +import pytest + +from sandbox.interaction import VelocityTracker +from sandbox.physics import Vector2 + +WINDOW = 0.1 +SMOOTHING = 0.05 + + +def _track(velocity: Vector2, fps: float, seconds: float) -> VelocityTracker: + """Bewegt einen Zeiger mit konstanter Geschwindigkeit und liefert den Tracker.""" + tracker = VelocityTracker(WINDOW, SMOOTHING) + tracker.reset(0.0, Vector2(0, 0)) + frames = round(seconds * fps) + for frame in range(1, frames + 1): + t = frame / fps + tracker.add_sample(t, velocity * t) + return tracker + + +def test_constant_motion_is_measured_exactly() -> None: + tracker = _track(Vector2(600, -200), fps=60, seconds=0.5) + assert tracker.velocity.is_close(Vector2(600, -200), tolerance=1.0) + + +def test_result_does_not_depend_on_the_frame_rate() -> None: + slow = _track(Vector2(500, 0), fps=30, seconds=0.5) + fast = _track(Vector2(500, 0), fps=144, seconds=0.5) + assert slow.velocity.x == pytest.approx(fast.velocity.x, rel=0.01) + + +def test_velocity_decays_when_the_pointer_stops() -> None: + tracker = _track(Vector2(800, 0), fps=60, seconds=0.3) + resting = Vector2(800 * 0.3, 0) + for frame in range(1, 31): # eine halbe Sekunde stillhalten + tracker.add_sample(0.3 + frame / 60, resting) + if frame == 12: # nach 0,2 s ist der Schwung auf wenige Prozent abgeklungen + assert tracker.velocity.length() < 0.1 * 800 + assert tracker.velocity.length() < 1.0 + + +def test_jitter_is_smoothed() -> None: + rng = random.Random(0) + tracker = VelocityTracker(WINDOW, SMOOTHING) + tracker.reset(0.0, Vector2(0, 0)) + for frame in range(1, 61): + t = frame / 60 + noise = Vector2(rng.uniform(-3, 3), rng.uniform(-3, 3)) # zittrige Hand + tracker.add_sample(t, Vector2(400 * t, 0) + noise) + assert tracker.velocity.x == pytest.approx(400, rel=0.1) + assert abs(tracker.velocity.y) < 60 + + +def test_samples_with_the_same_timestamp_replace_each_other() -> None: + tracker = VelocityTracker(WINDOW, SMOOTHING) + tracker.reset(0.0, Vector2(0, 0)) + tracker.add_sample(0.0, Vector2(50, 0)) # gleicher Frame: keine Division durch 0 + assert tracker.velocity == Vector2(0, 0) + tracker.add_sample(0.1, Vector2(50, 0)) + assert tracker.velocity.x == 0 # die ersetzte Position ist der neue Bezugspunkt + + +def test_first_sample_without_reset_starts_a_measurement() -> None: + tracker = VelocityTracker(WINDOW, SMOOTHING) + tracker.add_sample(1.0, Vector2(5, 5)) + assert tracker.velocity == Vector2(0, 0) + + +def test_invalid_parameters_are_rejected() -> None: + with pytest.raises(ValueError, match="positiv"): + VelocityTracker(0, SMOOTHING) diff --git a/tests/physics/__init__.py b/tests/physics/__init__.py new file mode 100644 index 0000000..5bc0e17 --- /dev/null +++ b/tests/physics/__init__.py @@ -0,0 +1 @@ +"""Tests der pygame-freien Physik-Schicht.""" diff --git a/tests/physics/test_bodies.py b/tests/physics/test_bodies.py new file mode 100644 index 0000000..ad3ef26 --- /dev/null +++ b/tests/physics/test_bodies.py @@ -0,0 +1,171 @@ +"""Tests für Formen, Materialien und Körper (Masse, Trägheit, Impulse, Zustände).""" + +import math +from collections.abc import Callable + +import pytest + +from sandbox.physics import Body, Box, Circle, HalfPlane, Material, Vector2 +from sandbox.physics.vector import ZERO +from tests.helpers import material + + +def test_circle_area_and_moment_of_inertia() -> None: + circle = Circle(10) + assert circle.area() == pytest.approx(math.pi * 100) + assert circle.moment_of_inertia(mass=2) == pytest.approx(0.5 * 2 * 100) + assert circle.half_extents() == (10, 10) + + +def test_box_area_and_infinite_inertia() -> None: + box = Box(3, 2) + assert box.area() == 24 + assert math.isinf(box.moment_of_inertia(mass=5)) + assert box.half_extents() == (3, 2) + + +@pytest.mark.parametrize( + "factory", + [lambda: Circle(0), lambda: Circle(-1), lambda: Box(0, 1), lambda: Box(1, -2)], +) +def test_invalid_shapes_are_rejected(factory: Callable[[], object]) -> None: + with pytest.raises(ValueError, match="positiv"): + factory() + + +def test_half_plane_requires_unit_normal() -> None: + with pytest.raises(ValueError, match="Länge 1"): + HalfPlane(Vector2(0, 2), 0) + + +def test_half_plane_distance_and_contains() -> None: + floor = HalfPlane(Vector2(0, -1), -100) # frei ist y ≤ 100 + assert floor.distance(Vector2(0, 90)) == pytest.approx(10) + assert floor.distance(Vector2(0, 110)) == pytest.approx(-10) + assert floor.contains(Vector2(0, 110)) + assert not floor.contains(Vector2(0, 90)) + assert floor.contains(Vector2(0, 90), tolerance=10) + assert math.isinf(floor.half_extents()[0]) + + +def test_point_containment_with_tolerance() -> None: + circle = Body.circle(Vector2(100, 100), 10, material()) + assert circle.contains_point(Vector2(107, 107)) + assert not circle.contains_point(Vector2(110, 110)) + assert circle.contains_point(Vector2(110, 110), tolerance=5) + + box = Body.box(Vector2(0, 0), 10, 5, material()) + assert box.contains_point(Vector2(10, -5)) + assert not box.contains_point(Vector2(10.5, 0)) + assert box.contains_point(Vector2(10.5, 0), tolerance=1) + + +def test_mass_is_area_times_density() -> None: + circle = Body.circle(Vector2(), 10, material(density=2)) + assert circle.mass == pytest.approx(math.pi * 100 * 2) + assert circle.inv_mass == pytest.approx(1 / circle.mass) + box = Body.box(Vector2(), 10, 5, material(density=0.5)) + assert box.mass == pytest.approx(20 * 10 * 0.5) + + +def test_bigger_bodies_are_heavier() -> None: + small = Body.circle(Vector2(), 10, material()) + large = Body.circle(Vector2(), 20, material()) + assert large.mass == pytest.approx(4 * small.mass) # Fläche wächst mit r² + + +def test_static_bodies_have_infinite_mass_and_never_move() -> None: + wall = Body.wall(Vector2(0, 1), 0, material()) + assert wall.is_static + assert wall.inv_mass == 0 + assert wall.inv_inertia == 0 + assert math.isinf(wall.mass) + wall.apply_impulse(Vector2(100, 100)) + assert wall.velocity == ZERO + assert wall.kinetic_energy() == 0 + assert wall.momentum() == ZERO + + +def test_half_planes_cannot_be_dynamic() -> None: + with pytest.raises(ValueError, match="statisch"): + Body(HalfPlane(Vector2(0, 1), 0), Vector2(), material()) + + +def test_dynamic_bodies_need_positive_density() -> None: + with pytest.raises(ValueError, match="Dichte"): + Body.circle(Vector2(), 10, material(density=0)) + + +@pytest.mark.parametrize( + ("density", "restitution", "friction"), + [(-1, 0.5, 0.5), (1, -0.1, 0.5), (1, 1.5, 0.5), (1, 0.5, -1)], +) +def test_invalid_materials_are_rejected( + density: float, restitution: float, friction: float +) -> None: + with pytest.raises(ValueError, match=r"Dichte|Restitution|Reibung"): + Material(density, restitution, friction) + + +def test_circles_can_rotate_but_boxes_cannot() -> None: + circle = Body.circle(Vector2(), 10, material()) + box = Body(Box(10, 10), Vector2(), material(), angular_velocity=5) + assert circle.can_rotate + assert not box.can_rotate + assert box.angular_velocity == 0 # Drehung wird bei Rechtecken ignoriert + + +def test_off_center_impulse_spins_a_circle() -> None: + circle = Body.circle(Vector2(), 10, material()) + # Stoß nach rechts an der Unterseite: erzeugt Vorwärtsbewegung und Drehung. + circle.apply_impulse(Vector2(circle.mass * 10, 0), offset=Vector2(0, 10)) + assert circle.velocity.is_close(Vector2(10, 0)) + expected_spin = circle.inv_inertia * Vector2(0, 10).cross(Vector2(circle.mass * 10, 0)) + assert circle.angular_velocity == pytest.approx(expected_spin) + assert circle.angular_velocity < 0 + + +def test_off_center_impulse_does_not_spin_a_box() -> None: + box = Body.box(Vector2(), 10, 10, material()) + box.apply_impulse(Vector2(box.mass, 0), offset=Vector2(0, 10)) + assert box.angular_velocity == 0 + assert box.velocity.is_close(Vector2(1, 0)) + + +def test_kinetic_energy_and_momentum() -> None: + circle = Body.circle(Vector2(), 10, material(), velocity=Vector2(3, 4)) + circle.angular_velocity = 2 + expected = 0.5 * circle.mass * 25 + 0.5 * circle.inertia * 4 + assert circle.kinetic_energy() == pytest.approx(expected) + assert circle.momentum().is_close(Vector2(3, 4) * circle.mass) + + +def test_bounds() -> None: + assert Body.circle(Vector2(10, 20), 5, material()).bounds() == (5, 15, 15, 25) + assert Body.box(Vector2(10, 20), 4, 2, material()).bounds() == (6, 18, 14, 22) + + +def test_sleep_wake_and_teleport() -> None: + body = Body.circle(Vector2(0, 0), 10, material(), velocity=Vector2(5, 0)) + body.angular_velocity = 1 + body.sleep_time = 1 + body.put_to_sleep() + assert body.sleeping + assert body.velocity == ZERO + assert body.angular_velocity == 0 + body.teleport(Vector2(50, 60)) + assert not body.sleeping + assert body.sleep_time == 0 + assert body.position == body.previous_position == Vector2(50, 60) + + +def test_ids_are_unique() -> None: + bodies = [Body.circle(Vector2(), 10, material()) for _ in range(5)] + assert len({body.id for body in bodies}) == 5 + + +def test_repr_mentions_id_and_sleep_state() -> None: + body = Body.circle(Vector2(1, 2), 10, material()) + assert f"id={body.id}" in repr(body) + body.put_to_sleep() + assert "schläft" in repr(body) diff --git a/tests/physics/test_broadphase.py b/tests/physics/test_broadphase.py new file mode 100644 index 0000000..59e2a70 --- /dev/null +++ b/tests/physics/test_broadphase.py @@ -0,0 +1,56 @@ +"""Tests der Broadphase (Sweep-and-Prune).""" + +import random + +from sandbox.physics import Body, Vector2 +from sandbox.physics.broadphase import find_candidate_pairs +from tests.helpers import material + + +def _random_scene(seed: int, count: int) -> list[Body]: + rng = random.Random(seed) + bodies = [] + for _ in range(count): + position = Vector2(rng.uniform(0, 400), rng.uniform(0, 300)) + if rng.random() < 0.5: + bodies.append(Body.circle(position, rng.uniform(5, 30), material())) + else: + bodies.append(Body.box(position, rng.uniform(5, 30), rng.uniform(5, 30), material())) + return bodies + + +def _overlap(a: Body, b: Body) -> bool: + a_min_x, a_min_y, a_max_x, a_max_y = a.bounds() + b_min_x, b_min_y, b_max_x, b_max_y = b.bounds() + return a_min_x <= b_max_x and b_min_x <= a_max_x and a_min_y <= b_max_y and b_min_y <= a_max_y + + +def test_matches_brute_force_on_random_scenes() -> None: + for seed in range(5): + bodies = _random_scene(seed, 60) + expected = { + (min(a.id, b.id), max(a.id, b.id)) + for i, a in enumerate(bodies) + for b in bodies[i + 1 :] + if _overlap(a, b) + } + found = [(a.id, b.id) for a, b in find_candidate_pairs(bodies)] + assert len(found) == len(set(found)), "kein Paar doppelt" + assert set(found) == expected + + +def test_pairs_are_ordered_by_id() -> None: + bodies = _random_scene(7, 40) + for a, b in find_candidate_pairs(bodies): + assert a.id < b.id + + +def test_pairs_of_sleeping_bodies_are_skipped() -> None: + a = Body.circle(Vector2(0, 0), 10, material()) + b = Body.circle(Vector2(15, 0), 10, material()) + c = Body.circle(Vector2(30, 0), 10, material()) + a.put_to_sleep() + b.put_to_sleep() + pairs = {(x.id, y.id) for x, y in find_candidate_pairs([a, b, c])} + assert (a.id, b.id) not in pairs # beide schlafen + assert (b.id, c.id) in pairs # schlafend + wach wird geprüft diff --git a/tests/physics/test_collision.py b/tests/physics/test_collision.py new file mode 100644 index 0000000..e1ccc54 --- /dev/null +++ b/tests/physics/test_collision.py @@ -0,0 +1,244 @@ +"""Tests der Kollisionserkennung für alle Formpaarungen einschließlich Randfällen.""" + +import random + +import pytest + +from sandbox.physics import Body, Vector2, collide +from sandbox.physics.collision import ( + COINCIDENT_NORMAL, + collide_box_half_plane, + collide_boxes, + collide_circle_box, + collide_circle_half_plane, + collide_circles, +) +from tests.helpers import material + + +def circle(x: float, y: float, radius: float) -> Body: + return Body.circle(Vector2(x, y), radius, material()) + + +def box(x: float, y: float, half_width: float, half_height: float) -> Body: + return Body.box(Vector2(x, y), half_width, half_height, material()) + + +def floor_at(y: float) -> Body: + return Body.wall(Vector2(0, -1), -y, material()) # frei ist alles oberhalb von y + + +# --------------------------------------------------------------------- Kreis–Kreis + + +def test_separated_circles_do_not_collide() -> None: + assert collide_circles(circle(0, 0, 10), circle(21, 0, 10)) is None + + +def test_touching_circles_form_a_contact_without_penetration() -> None: + contact = collide_circles(circle(0, 0, 10), circle(20, 0, 10)) + assert contact is not None + assert contact.penetration == pytest.approx(0) + + +def test_overlapping_circles() -> None: + a = circle(0, 0, 10) + b = circle(15, 0, 10) + contact = collide_circles(a, b) + assert contact is not None + assert contact.body_a is a + assert contact.body_b is b + assert contact.normal.is_close(Vector2(1, 0)) + assert contact.penetration == pytest.approx(5) + assert contact.point.is_close(Vector2(7.5, 0)) # Mitte des Überlappungsbereichs + + +def test_coincident_circles_use_a_fixed_fallback_normal() -> None: + contact = collide_circles(circle(5, 5, 10), circle(5, 5, 8)) + assert contact is not None + assert contact.normal == COINCIDENT_NORMAL + assert contact.penetration == pytest.approx(18) + + +# --------------------------------------------------------------------- Kreis–Rechteck + + +def test_circle_touching_the_top_face_of_a_box() -> None: + contact = collide_circle_box(circle(0, -14, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis (oben) zur Box (unten) + assert contact.penetration == pytest.approx(1) + assert contact.point.is_close(Vector2(0, -10)) + + +def test_circle_touching_the_right_face_of_a_box() -> None: + contact = collide_circle_box(circle(24, 0, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(-1, 0)) + assert contact.penetration == pytest.approx(1) + + +def test_circle_touching_a_corner_gets_a_diagonal_normal() -> None: + contact = collide_circle_box(circle(23, -13, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(-1, 1).normalized()) + assert contact.penetration == pytest.approx(5 - 18**0.5) + assert contact.point.is_close(Vector2(20, -10)) + + +def test_circle_near_a_corner_but_outside_does_not_collide() -> None: + # Die umschließenden Boxen überlappen, der Kreis verfehlt die Ecke aber knapp. + assert collide_circle_box(circle(24, -14, 5), box(0, 0, 20, 10)) is None + + +def test_circle_center_inside_box_is_pushed_out_through_the_nearest_side_face() -> None: + contact = collide_circle_box(circle(15, 2, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(-1, 0)) # Kreis bewegt sich entlang −n nach rechts + assert contact.penetration == pytest.approx(5 + 5) # Radius + Abstand zur Kante + assert contact.point.is_close(Vector2(20, 2)) + + +def test_circle_center_inside_box_is_pushed_out_through_the_nearest_top_face() -> None: + contact = collide_circle_box(circle(3, -8, 5), box(0, 0, 20, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, 1)) # Kreis bewegt sich nach oben + assert contact.penetration == pytest.approx(5 + 2) + assert contact.point.is_close(Vector2(3, -10)) + + +# --------------------------------------------------------------------- Rechteck–Rechteck + + +def test_boxes_are_separated_along_the_axis_of_least_overlap() -> None: + a = box(0, 0, 10, 10) + contact = collide_boxes(a, box(15, 5, 10, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(1, 0)) + assert contact.penetration == pytest.approx(5) + assert contact.point.is_close(Vector2(7.5, 2.5)) # Mitte des Überlappungsrechtecks + + contact = collide_boxes(a, box(5, -18, 10, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, -1)) + assert contact.penetration == pytest.approx(2) + + +def test_equal_overlap_prefers_the_vertical_axis() -> None: + contact = collide_boxes(box(0, 0, 10, 10), box(15, 15, 10, 10)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, 1)) + + +@pytest.mark.parametrize(("x", "y"), [(21, 0), (0, 25), (-21, 5), (3, -20.5)]) +def test_separated_boxes_do_not_collide(x: float, y: float) -> None: + assert collide_boxes(box(0, 0, 10, 10), box(x, y, 10, 10)) is None + + +# --------------------------------------------------------------------- Wände + + +def test_circle_resting_on_the_floor() -> None: + contact = collide_circle_half_plane(circle(50, 95, 10), floor_at(100)) + assert contact is not None + assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis in die Wand + assert contact.penetration == pytest.approx(5) + assert contact.point.is_close(Vector2(50, 100)) + + +def test_circle_above_the_floor_does_not_collide() -> None: + assert collide_circle_half_plane(circle(50, 80, 10), floor_at(100)) is None + + +def test_circle_completely_behind_the_wall_is_still_detected() -> None: + # Der "Tunnel"-Fall: Selbst ein Körper hinter der Wandlinie wird erkannt + # und in den freien Raum zurückgeschoben. + contact = collide_circle_half_plane(circle(50, 130, 10), floor_at(100)) + assert contact is not None + assert contact.penetration == pytest.approx(40) + assert contact.normal.is_close(Vector2(0, 1)) + + +def test_box_against_floor_and_side_wall() -> None: + contact = collide_box_half_plane(box(50, 95, 20, 10), floor_at(100)) + assert contact is not None + assert contact.penetration == pytest.approx(5) + + left_wall = Body.wall(Vector2(1, 0), 0, material()) + contact = collide_box_half_plane(box(15, 50, 20, 10), left_wall) + assert contact is not None + assert contact.normal.is_close(Vector2(-1, 0)) + assert contact.penetration == pytest.approx(5) + assert collide_box_half_plane(box(25, 50, 20, 10), left_wall) is None + + +# --------------------------------------------------------------------- Zuordnung + + +def test_collide_orders_bodies_canonically() -> None: + c = circle(0, -14, 5) + b = box(0, 0, 20, 10) + for first, second in ((c, b), (b, c)): + contact = collide(first, second) + assert contact is not None + assert contact.body_a is c + assert contact.body_b is b + assert contact.normal.is_close(Vector2(0, 1)) + + wall = floor_at(100) + contact = collide(wall, circle(50, 95, 10)) + assert contact is not None + assert contact.body_b is wall + + +def test_two_walls_never_collide() -> None: + assert collide(floor_at(100), floor_at(50)) is None + + +def test_contact_key_and_repr() -> None: + a = circle(0, 0, 10) + b = circle(15, 0, 10) + contact = collide(a, b) + assert contact is not None + assert contact.key == (a.id, b.id) + assert "penetration=5.000" in repr(contact) + + +# --------------------------------------------------------------------- Eigenschaften + + +def _random_body(rng: random.Random) -> Body: + x = rng.uniform(-40, 40) + y = rng.uniform(-40, 40) + if rng.random() < 0.5: + return circle(x, y, rng.uniform(5, 30)) + return box(x, y, rng.uniform(5, 30), rng.uniform(5, 30)) + + +def _random_wall(rng: random.Random) -> Body: + normal = rng.choice([Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)]) + return Body.wall(normal, rng.uniform(-40, 40), material()) + + +def test_random_contacts_are_consistent_and_resolvable() -> None: + """Zufällige Kontakte sind in sich stimmig und lassen sich exakt auflösen. + + Für viele Zufallspaare gilt: Die Normale ist ein Einheitsvektor, die Tiefe ist ≥ 0, und + verschiebt man Körper A um (Tiefe + ε) entgegen der Normalen, berühren sich die Körper + nicht mehr. Genau diese Information braucht die Kollisionsauflösung. + """ + rng = random.Random(1234) + checked = 0 + for _ in range(3000): + first = _random_body(rng) + second = _random_wall(rng) if rng.random() < 0.25 else _random_body(rng) + contact = collide(first, second) + if contact is None: + continue + checked += 1 + assert contact.normal.length() == pytest.approx(1) + assert contact.penetration >= 0 + mover = contact.body_a + mover.position = mover.position - contact.normal * (contact.penetration + 1e-6) + assert collide(contact.body_a, contact.body_b) is None + assert checked > 500 # genug echte Kontakte geprüft diff --git a/tests/physics/test_sleep.py b/tests/physics/test_sleep.py new file mode 100644 index 0000000..2595e2a --- /dev/null +++ b/tests/physics/test_sleep.py @@ -0,0 +1,105 @@ +"""Tests des Schlafmodus (Inseln, Einschlafen, Aufwecken).""" + +from dataclasses import replace + +from sandbox.config import PHYSICS +from sandbox.physics import Body, Contact, Vector2, collide +from sandbox.physics.sleep import UnionFind, find_islands, update_sleep, wake_touched_bodies +from tests.helpers import material + + +def _circle(x: float, y: float = 0.0, radius: float = 10.0) -> Body: + return Body.circle(Vector2(x, y), radius, material()) + + +def _contacts(*pairs: tuple[Body, Body]) -> list[Contact]: + contacts = [collide(a, b) for a, b in pairs] + assert all(contact is not None for contact in contacts) + return [contact for contact in contacts if contact is not None] + + +def test_union_find_merges_sets() -> None: + union_find = UnionFind([1, 2, 3, 4, 5]) + union_find.union(1, 2) + union_find.union(3, 4) + union_find.union(2, 4) + assert union_find.find(1) == union_find.find(3) == union_find.find(4) + assert union_find.find(5) != union_find.find(1) + + +def test_touching_bodies_form_islands_but_walls_do_not_connect_them() -> None: + a, b, c, d = _circle(0), _circle(15), _circle(30), _circle(200) + floor = Body.wall(Vector2(0, -1), -5, material()) # berührt alle Kreise + contacts = _contacts((a, b), (b, c), (a, floor), (d, floor)) + islands = find_islands([a, b, c, d], contacts) + assert [set(island) for island in islands] == [{a, b, c}, {d}] + + +def test_an_island_sleeps_only_when_all_its_bodies_rest() -> None: + config = replace(PHYSICS, time_to_sleep=0.5) + resting, moving = _circle(0), _circle(15) + moving.velocity = Vector2(50, 0) + contacts = _contacts((resting, moving)) + for _ in range(100): + update_sleep([resting, moving], contacts, 0.01, config) + assert resting.sleep_time >= 0.5 + assert not resting.sleeping # der bewegte Nachbar hält die Insel wach + + moving.velocity = Vector2(0, 0) + for _ in range(60): + update_sleep([resting, moving], contacts, 0.01, config) + assert resting.sleeping + assert moving.sleeping + + +def test_waking_one_body_wakes_its_whole_island() -> None: + a, b, c, outsider = _circle(0), _circle(15), _circle(30), _circle(300) + island = [a, b, c] + for body in island: + body.put_to_sleep(island) + outsider.put_to_sleep() + c.wake() + assert not any(body.sleeping for body in island) + assert all(body.sleep_island is None for body in island) + assert outsider.sleeping + + +def test_touching_a_sleeper_wakes_and_reports_its_whole_island() -> None: + a, b, c = _circle(0), _circle(15), _circle(30) + island = [b, c] + for body in island: + body.put_to_sleep(island) + woken = wake_touched_bodies(_contacts((a, b))) + assert set(woken) == {b, c} + assert not c.sleeping + + +def test_held_bodies_keep_their_island_awake() -> None: + held, other = _circle(0), _circle(15) + held.held = True + contacts = _contacts((held, other)) + for _ in range(200): + update_sleep([held, other], contacts, 0.01, PHYSICS) + assert not held.sleeping + assert not other.sleeping + assert held.sleep_time == 0 + + +def test_sleepers_touched_by_awake_bodies_are_woken_but_not_by_walls() -> None: + sleeper, awake = _circle(0), _circle(15) + sleeper.put_to_sleep() + floor = Body.wall(Vector2(0, -1), -5, material()) + lonely = _circle(300) + lonely.put_to_sleep() + both_asleep = _circle(500) + both_asleep.put_to_sleep() + neighbour = _circle(515) + neighbour.put_to_sleep() + + woken = wake_touched_bodies( + _contacts((sleeper, awake), (lonely, floor), (both_asleep, neighbour)) + ) + assert woken == [sleeper] + assert not sleeper.sleeping + assert lonely.sleeping + assert both_asleep.sleeping diff --git a/tests/physics/test_solver.py b/tests/physics/test_solver.py new file mode 100644 index 0000000..340ec4b --- /dev/null +++ b/tests/physics/test_solver.py @@ -0,0 +1,186 @@ +"""Tests der Kollisionsauflösung: Stöße, Restitution, Reibung, Rollen, Positionskorrektur. + +Die Erwartungswerte stammen aus der klassischen Mechanik (Impuls- und Energieerhaltung, +Coulomb-Reibung, Rollbedingung v = ω·r) und nicht aus der Implementierung selbst. +""" + +import math + +import pytest + +from sandbox.config import PHYSICS +from tests.helpers import ( + add_box, + add_circle, + make_world, + simulate, + total_kinetic_energy, + total_momentum, +) + +GRAVITY = PHYSICS.gravity[1] +FLOOR = 700.0 + + +def _head_on_pair(restitution: float) -> tuple: + """Zwei Kreise ohne Schwerkraft, Massenverhältnis 1:3, frontal aufeinander zu.""" + world = make_world(gravity=(0.0, 0.0)) + light = add_circle(world, 300, 350, 20, velocity=(200, 0), restitution=restitution) + heavy = add_circle( + world, 500, 350, 20, velocity=(-100, 0), density=3.0, restitution=restitution + ) + return world, light, heavy + + +def test_elastic_collision_conserves_momentum_and_energy() -> None: + world, light, heavy = _head_on_pair(restitution=1.0) + momentum_before = total_momentum(world.bodies) + energy_before = total_kinetic_energy(world.bodies) + simulate(world, 1.0) # Stoß bei t ≈ 0,53 s, danach noch weit weg von den Wänden + + assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6) + assert total_kinetic_energy(world.bodies) == pytest.approx(energy_before, rel=1e-9) + # Lehrbuchformel für den 1D-Stoß: v1' = ((m1 − m2)·v1 + 2·m2·v2) / (m1 + m2) + m1, m2, v1, v2 = light.mass, heavy.mass, 200.0, -100.0 + assert light.velocity.x == pytest.approx(((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2)) + assert heavy.velocity.x == pytest.approx(((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2)) + assert light.velocity.x == pytest.approx(-250) + assert heavy.velocity.x == pytest.approx(50) + + +def test_equal_masses_exchange_velocities_in_an_elastic_collision() -> None: + world = make_world(gravity=(0.0, 0.0)) + a = add_circle(world, 300, 350, 20, velocity=(150, 0), restitution=1.0) + b = add_circle(world, 400, 350, 20, restitution=1.0) + simulate(world, 0.8) + assert a.velocity.x == pytest.approx(0, abs=1e-9) + assert b.velocity.x == pytest.approx(150) + + +def test_perfectly_inelastic_collision_leaves_a_common_velocity() -> None: + world, light, heavy = _head_on_pair(restitution=0.0) + momentum_before = total_momentum(world.bodies) + energy_before = total_kinetic_energy(world.bodies) + simulate(world, 1.0) + + common = momentum_before.x / (light.mass + heavy.mass) # = −25 px/s + assert light.velocity.x == pytest.approx(common) + assert heavy.velocity.x == pytest.approx(common) + assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6) + assert total_kinetic_energy(world.bodies) < energy_before + + +def test_restitution_below_one_loses_energy_as_predicted() -> None: + world, light, heavy = _head_on_pair(restitution=0.5) + energy_before = total_kinetic_energy(world.bodies) + simulate(world, 1.0) + # Newtons Stoßgesetz: Trenngeschwindigkeit = e · Aufprallgeschwindigkeit (300 px/s). + assert heavy.velocity.x - light.velocity.x == pytest.approx(0.5 * 300) + # Energieverlust beim zentralen Stoß: ΔE = ½·μ·(1 − e²)·v_rel² mit μ = m1·m2/(m1 + m2). + reduced_mass = light.mass * heavy.mass / (light.mass + heavy.mass) + expected_loss = 0.5 * reduced_mass * (1 - 0.5**2) * 300**2 + energy_loss = energy_before - total_kinetic_energy(world.bodies) + assert energy_loss == pytest.approx(expected_loss, rel=1e-6) + + +def test_slow_impacts_below_the_threshold_do_not_bounce() -> None: + world = make_world(gravity=(0.0, 0.0)) + approach = 0.8 * PHYSICS.restitution_threshold + a = add_circle(world, 300, 350, 20, velocity=(approach, 0), restitution=1.0) + b = add_circle(world, 345, 350, 20, restitution=1.0) + simulate(world, 0.5) + assert b.velocity.x - a.velocity.x == pytest.approx(0, abs=1e-9) + + +def test_frictionless_oblique_impact_keeps_the_tangential_velocity() -> None: + world = make_world(gravity=(0.0, 0.0), wall_friction=0.0) + ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), restitution=1.0, friction=0.0) + simulate(world, 0.4) # trifft die linke Wand bei t ≈ 0,27 s + assert ball.velocity.x == pytest.approx(300) + assert ball.velocity.y == pytest.approx(120) + assert ball.angular_velocity == 0 # Normalimpulse erzeugen kein Drehmoment + + +def test_friction_on_an_oblique_impact_makes_a_ball_spin() -> None: + world = make_world(gravity=(0.0, 0.0)) + ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), friction=0.8) + simulate(world, 0.4) + assert ball.angular_velocity != 0 + assert abs(ball.velocity.y) < 120 # Reibung hat die Tangentialgeschwindigkeit gebremst + + +def test_sliding_box_decelerates_with_coulomb_friction() -> None: + world = make_world(wall_friction=0.6) + box = add_box(world, 200, FLOOR - 20, 20, 20, velocity=(300, 0), friction=0.4) + simulate(world, 1.5) + mu = math.sqrt(0.4 * 0.6) # gemischter Reibwert Box/Boden + expected_distance = 300**2 / (2 * mu * GRAVITY) # s = v² / (2·μ·g) + assert box.position.x - 200 == pytest.approx(expected_distance, rel=0.03) + assert box.velocity.x == pytest.approx(0, abs=1e-6) + + +def test_sliding_disk_starts_rolling_at_two_thirds_of_its_speed() -> None: + """Eine rutschende Scheibe geht ins Rollen über und behält dabei ⅔ ihres Tempos. + + Reibung bremst die Scheibe (I = ½·m·r²) und versetzt sie in Drehung, bis v = ω·r gilt. + Drehimpulserhaltung um den Kontaktpunkt liefert dann v = v0 / (1 + I/(m·r²)) = ⅔·v0 – + unabhängig vom Reibwert. + """ + world = make_world(rolling_resistance=0.0, sleep_enabled=False) + radius = 20 + disk = add_circle(world, 200, FLOOR - radius, radius, velocity=(300, 0), friction=0.5) + simulate(world, 1.0) + assert disk.velocity.x == pytest.approx(200, rel=0.01) + assert disk.angular_velocity * radius == pytest.approx(disk.velocity.x, rel=0.01) + + +def test_rolling_resistance_decelerates_a_rolling_ball() -> None: + """Rollwiderstand: Moment μ_r·N·r ⇒ Verzögerung a = μ_r·g / (1 + I/(m·r²)) = μ_r·g/1,5.""" + world = make_world(sleep_enabled=False) + radius = 25 + ball = add_circle( + world, 200, FLOOR - radius, radius, velocity=(200, 0), angular_velocity=200 / radius + ) + simulate(world, 1.0) + expected_deceleration = PHYSICS.rolling_resistance * GRAVITY / 1.5 + assert 200 - ball.velocity.x == pytest.approx(expected_deceleration, rel=0.02) + + +def test_resting_contact_carries_exactly_the_weight() -> None: + world = make_world(sleep_enabled=False) + box = add_box(world, 300, FLOOR - 20, 30, 20) + simulate(world, 0.5) + (contact,) = world.contacts + # Pro Schritt muss der Boden den Impuls m·g·dt aufbringen, damit die Box liegen bleibt. + assert contact.normal_impulse == pytest.approx(box.mass * GRAVITY * PHYSICS.time_step) + assert contact.tangent_impulse == pytest.approx(0, abs=1e-9) + + +def test_impulses_respect_non_penetration_and_coulomb_bounds() -> None: + world = make_world() + for i in range(12): + add_circle(world, 300 + 25 * (i % 4), 200 + 30 * (i // 4), 14, velocity=(40 * i, 0)) + add_box(world, 600 + 30 * (i % 3), 150 + 35 * (i // 3), 14, 16) + for _ in range(240): + world.step(PHYSICS.time_step) + for contact in world.contacts: + assert contact.normal_impulse >= 0 + mu = math.sqrt(contact.body_a.material.friction * contact.body_b.material.friction) + assert abs(contact.tangent_impulse) <= mu * contact.normal_impulse + 1e-9 + + +def test_position_correction_separates_without_adding_velocity() -> None: + world = make_world(gravity=(0.0, 0.0), sleep_enabled=False) + a = add_circle(world, 500, 350, 20) + b = add_circle(world, 510, 350, 20) # 30 px Überlappung + simulate(world, 1.0) + assert a.position.distance_to(b.position) >= 40 - PHYSICS.penetration_slop - 1e-9 + assert a.velocity.length() == 0 + assert b.velocity.length() == 0 + + +def test_warm_starting_can_be_disabled() -> None: + world = make_world(warm_starting=False) + box = add_box(world, 300, FLOOR - 20, 30, 20) + simulate(world, 2.0) + assert box.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop) diff --git a/tests/physics/test_timestep.py b/tests/physics/test_timestep.py new file mode 100644 index 0000000..dd8d1cc --- /dev/null +++ b/tests/physics/test_timestep.py @@ -0,0 +1,57 @@ +"""Tests des festen Zeitschritts (Akkumulator).""" + +import pytest + +from sandbox.physics import FixedTimestep + +STEP = 1 / 120 + + +def test_a_60_fps_frame_runs_two_physics_steps() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + assert timestep.advance(1 / 60) == 2 + assert timestep.alpha == pytest.approx(0, abs=1e-6) + + +def test_short_frames_accumulate() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + assert timestep.advance(1 / 240) == 0 + assert timestep.alpha == pytest.approx(0.5) + assert timestep.advance(1 / 240) == 1 + + +def test_no_steps_are_lost_through_rounding() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + total = sum(timestep.advance(1 / 60) for _ in range(1000)) + assert total == 2000 + + +def test_alpha_stays_in_the_unit_interval() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + for frame in (0.003, 0.011, 0.017, 0.0001, 0.02): + timestep.advance(frame) + assert 0 <= timestep.alpha < 1 + + +def test_huge_frames_are_capped_to_avoid_the_spiral_of_death() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=5) + assert timestep.advance(2.0) == 5 + assert timestep.alpha == 0 # der Rückstand wurde verworfen + assert timestep.advance(1 / 60) == 2 + + +def test_reset_discards_accumulated_time() -> None: + timestep = FixedTimestep(STEP, max_steps_per_frame=8) + timestep.advance(STEP * 0.9) + timestep.reset() + assert timestep.alpha == 0 + assert timestep.advance(STEP * 0.5) == 0 + + +def test_invalid_arguments_are_rejected() -> None: + with pytest.raises(ValueError, match="positiv"): + FixedTimestep(0, 8) + with pytest.raises(ValueError, match="mindestens"): + FixedTimestep(STEP, 0) + with pytest.raises(ValueError, match="negativ"): + FixedTimestep(STEP, 8).advance(-0.1) diff --git a/tests/physics/test_vector.py b/tests/physics/test_vector.py new file mode 100644 index 0000000..9930dd5 --- /dev/null +++ b/tests/physics/test_vector.py @@ -0,0 +1,127 @@ +"""Tests der eigenen Vektorklasse.""" + +import copy +import math +import pickle + +import pytest + +from sandbox.physics.vector import ZERO, Vector2 + + +def test_addition_and_subtraction() -> None: + assert Vector2(1, 2) + Vector2(3, -5) == Vector2(4, -3) + assert Vector2(1, 2) - Vector2(3, -5) == Vector2(-2, 7) + + +def test_scaling_from_both_sides_and_division() -> None: + assert Vector2(1.5, -2) * 2 == Vector2(3, -4) + assert 2 * Vector2(1.5, -2) == Vector2(3, -4) + assert Vector2(3, -4) / 2 == Vector2(1.5, -2) + + +def test_negation() -> None: + assert -Vector2(1, -2) == Vector2(-1, 2) + + +def test_dot_product() -> None: + assert Vector2(1, 2).dot(Vector2(3, 4)) == 11 + assert Vector2(1, 0).dot(Vector2(0, 5)) == 0 # orthogonal + + +def test_cross_product_sign_and_value() -> None: + assert Vector2(1, 0).cross(Vector2(0, 1)) == 1 + assert Vector2(0, 1).cross(Vector2(1, 0)) == -1 + assert Vector2(2, 3).cross(Vector2(4, 6)) == 0 # parallel + + +def test_length_and_length_squared() -> None: + v = Vector2(3, 4) + assert v.length() == 5 + assert v.length_squared() == 25 + + +def test_normalized_has_unit_length_and_same_direction() -> None: + n = Vector2(3, 4).normalized() + assert n.length() == pytest.approx(1.0) + assert n.is_close(Vector2(0.6, 0.8)) + + +def test_normalizing_the_zero_vector_returns_zero_instead_of_nan() -> None: + assert Vector2(0, 0).normalized() == ZERO + + +def test_perpendicular_is_rotated_by_plus_90_degrees() -> None: + v = Vector2(2, 1) + p = v.perpendicular() + assert p == Vector2(-1, 2) + assert v.dot(p) == 0 + assert v.cross(p) > 0 + + +def test_distance() -> None: + assert Vector2(1, 1).distance_to(Vector2(4, 5)) == 5 + + +def test_clamped_limits_only_long_vectors() -> None: + short = Vector2(1, 1) + assert short.clamped(10) is short + clamped = Vector2(30, 40).clamped(10) + assert clamped.length() == pytest.approx(10) + assert clamped.is_close(Vector2(6, 8)) + + +def test_lerp() -> None: + a = Vector2(0, 10) + b = Vector2(10, 20) + assert a.lerp(b, 0) == a + assert a.lerp(b, 1) == b + assert a.lerp(b, 0.5) == Vector2(5, 15) + + +def test_is_finite() -> None: + assert Vector2(1, 2).is_finite() + assert not Vector2(math.nan, 0).is_finite() + assert not Vector2(0, math.inf).is_finite() + + +def test_is_close_uses_absolute_tolerance() -> None: + assert Vector2(1, 1).is_close(Vector2(1 + 1e-12, 1)) + assert not Vector2(1, 1).is_close(Vector2(1.1, 1)) + assert Vector2(1, 1).is_close(Vector2(1.1, 1), tolerance=0.2) + + +def test_equality_hash_and_comparison_with_other_types() -> None: + assert Vector2(1, 2) == Vector2(1.0, 2.0) + assert Vector2(1, 2) != Vector2(2, 1) + assert Vector2(1, 2) != (1, 2) + assert len({Vector2(1, 2), Vector2(1, 2), Vector2(2, 1)}) == 2 + + +def test_vectors_are_immutable() -> None: + v = Vector2(1, 2) + with pytest.raises(AttributeError): + v.x = 5 # type: ignore[misc] + with pytest.raises(AttributeError): + del v.y + assert v == Vector2(1, 2) + + +def test_operations_do_not_modify_operands() -> None: + a = Vector2(1, 2) + b = Vector2(3, 4) + _ = (a + b, a - b, a * 3, a / 2, -a, a.normalized(), a.clamped(0.1), a.lerp(b, 0.5)) + assert a == Vector2(1, 2) + assert b == Vector2(3, 4) + + +def test_copy_and_pickle_round_trip() -> None: + v = Vector2(1.5, -2.5) + assert copy.copy(v) == v + assert copy.deepcopy(v) == v + assert pickle.loads(pickle.dumps(v)) == v + + +def test_repr_and_tuple() -> None: + assert repr(Vector2(1.5, 2)) == "Vector2(1.5, 2)" + assert Vector2(1.5, 2).to_tuple() == (1.5, 2) diff --git a/tests/physics/test_world.py b/tests/physics/test_world.py new file mode 100644 index 0000000..341fe23 --- /dev/null +++ b/tests/physics/test_world.py @@ -0,0 +1,446 @@ +"""Szenario-Tests der Physikwelt: Fallen, Aufprallen, Stapeln, Ruhen, Grenzen, Stabilität.""" + +import itertools +import random + +import pytest + +from sandbox.config import MIN_BODY_SIZE, PHYSICS, TIME_STEP +from sandbox.physics import Body, Vector2, World +from tests.helpers import ( + WORLD_HEIGHT, + WORLD_WIDTH, + add_box, + add_circle, + make_world, + material, + simulate, + total_kinetic_energy, +) + +GRAVITY = PHYSICS.gravity[1] +FLOOR = WORLD_HEIGHT +#: Geduldete Rest-Überlappung ruhender Körper: die Toleranz ``penetration_slop`` plus +#: ein tausendstel Pixel für die nie ganz auskonvergierte Iteration. +MAX_RESTING_OVERLAP = PHYSICS.penetration_slop + 1e-3 + + +# --------------------------------------------------------------------- Fallen und Aufprallen + + +def test_free_fall_matches_half_g_t_squared() -> None: + world = make_world(height=2000) + ball = add_circle(world, 500, 100, 10) + steps = 60 + simulate(world, steps * TIME_STEP) + t = steps * TIME_STEP + + fallen = ball.position.y - 100 + assert fallen == pytest.approx(0.5 * GRAVITY * t * t, rel=0.02) + # Semi-impliziter Euler exakt: v_k = k·g·dt und s_n = g·dt²·n·(n+1)/2. + assert ball.velocity.y == pytest.approx(GRAVITY * t) + assert fallen == pytest.approx(GRAVITY * TIME_STEP**2 * steps * (steps + 1) / 2) + + +def test_bodies_without_gravity_stay_put() -> None: + world = make_world() + world.gravity_enabled = False + ball = add_circle(world, 500, 100, 10) + simulate(world, 1.0) + assert ball.position == Vector2(500, 100) + assert world.gravity == Vector2(0, 0) + + +def test_fallen_ball_comes_to_rest_on_the_floor_and_never_leaves_the_world() -> None: + world = make_world() + radius = 20 + ball = add_circle(world, 500, 100, radius, restitution=0.6) + lowest = 0.0 + for _ in range(round(8 / TIME_STEP)): + world.step(TIME_STEP) + lowest = max(lowest, ball.position.y + radius) + assert ball.position.x - radius >= 0 + assert ball.position.x + radius <= WORLD_WIDTH + # Tiefer als die Notbremse erlaubt, sinkt der Ball nie in den Boden ein. + assert lowest <= FLOOR + PHYSICS.max_wall_penetration + 1e-9 + assert ball.sleeping + assert ball.velocity == Vector2(0, 0) + assert ball.position.y == pytest.approx(FLOOR - radius, abs=PHYSICS.penetration_slop) + + +def _first_bounce_apex(restitution: float) -> tuple[float, float]: + """Lässt einen Ball fallen und liefert (Fallhöhe, Steighöhe nach dem 1. Aufprall).""" + world = make_world() + radius = 20 + start_y = 200.0 + ball = add_circle(world, 500, start_y, radius, restitution=restitution) + resting_y = FLOOR - radius + bounced = False + apex = resting_y + for _ in range(round(4 / TIME_STEP)): + world.step(TIME_STEP) + if ball.velocity.y < 0: + bounced = True + apex = min(apex, ball.position.y) + elif bounced: + break # Scheitelpunkt überschritten + return resting_y - start_y, resting_y - apex + + +def test_bounce_height_follows_the_restitution_squared() -> None: + # Energieerhaltung beim Flug: h' / h = (v'/v)² = e². + drop, rise = _first_bounce_apex(restitution=0.8) + assert rise < drop # Energie geht verloren + assert rise == pytest.approx(0.8**2 * drop, rel=0.01) + + +def test_perfectly_elastic_ball_keeps_its_bounce_height() -> None: + """Ein vollkommen elastischer Ball springt immer wieder genau auf seine Fallhöhe. + + Er darf nie höher springen – sonst entstünde Energie aus dem Nichts, etwa durch die im + Aufprallschritt schon addierte Schwerkraft. + """ + world = make_world() + radius = 20 + start_y = 250.0 + ball = add_circle(world, 500, start_y, radius, restitution=1.0) + apexes = [] + rising = False + for _ in range(round(8 / TIME_STEP)): + world.step(TIME_STEP) + if ball.velocity.y < 0: + rising = True + elif rising: + apexes.append(ball.position.y) + rising = False + assert len(apexes) >= 4 + for apex in apexes: + assert apex == pytest.approx(start_y, abs=2.0) # 2 px bei 430 px Fallhöhe + + +def test_ball_without_restitution_does_not_bounce() -> None: + drop, rise = _first_bounce_apex(restitution=0.0) + assert drop > 0 + assert rise == pytest.approx(0, abs=1.0) + + +def test_total_energy_never_increases_without_external_forces() -> None: + world = make_world(gravity=(0.0, 0.0)) + rng = random.Random(42) + for _ in range(30): + velocity = (rng.uniform(-300, 300), rng.uniform(-300, 300)) + x, y = rng.uniform(60, 940), rng.uniform(60, 640) + if rng.random() < 0.5: + add_circle(world, x, y, rng.uniform(12, 35), velocity=velocity) + else: + add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35), velocity=velocity) + energy = total_kinetic_energy(world.bodies) + for _ in range(round(5 / TIME_STEP)): + world.step(TIME_STEP) + new_energy = total_kinetic_energy(world.bodies) + assert new_energy <= energy * (1 + 1e-9) + 1e-6 + energy = new_energy + + +# --------------------------------------------------------------------- Überlappen und Stapeln + + +def test_overlapping_bodies_are_pushed_apart() -> None: + world = make_world() + bottom = add_box(world, 500, FLOOR - 30, 40, 30) + top = add_box(world, 510, FLOOR - 70, 30, 30) # steckt 20 px in der unteren Box + simulate(world, 2.0) + gap = (bottom.position.y - 30) - (top.position.y + 30) + assert gap >= -MAX_RESTING_OVERLAP + + +def test_circle_spawned_inside_a_box_pops_out() -> None: + world = make_world() + base = add_box(world, 500, FLOOR - 40, 60, 40) + ball = add_circle(world, 520, FLOOR - 50, 15) # Mittelpunkt liegt in der Box + simulate(world, 2.0) + assert ball.position.y + 15 <= base.position.y - 40 + MAX_RESTING_OVERLAP + + +def test_box_stack_is_stable_and_falls_asleep() -> None: + world = make_world() + half_height = 20 + boxes = [ + add_box(world, 500, FLOOR - half_height - i * 2 * half_height, 30, half_height) + for i in range(6) + ] + simulate(world, 5.0) + for lower, upper in itertools.pairwise(boxes): + assert upper.position.x == pytest.approx(500, abs=1e-6) # kein seitliches Wandern + gap = (lower.position.y - half_height) - (upper.position.y + half_height) + assert -MAX_RESTING_OVERLAP <= gap <= 1e-9 + assert all(box.sleeping for box in boxes) + + +def test_pile_of_mixed_bodies_settles_completely() -> None: + world = make_world() + rng = random.Random(3) + for _ in range(60): + x, y = rng.uniform(60, 940), rng.uniform(40, 500) + if rng.random() < 0.5: + add_circle(world, x, y, rng.uniform(12, 35)) + else: + add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35)) + simulate(world, 8.0) + assert all(body.sleeping for body in world.bodies) + + +# --------------------------------------------------------------------- Geschwindigkeit und Tunneln + + +def test_speed_is_clamped_to_the_maximum() -> None: + world = make_world(gravity=(0.0, 0.0)) + ball = add_circle(world, 500, 350, 20, velocity=(50_000, 0)) + world.step(TIME_STEP) + assert ball.velocity.length() == pytest.approx(PHYSICS.max_speed) + + +def test_spin_is_clamped_to_the_maximum_in_both_directions() -> None: + world = make_world(gravity=(0.0, 0.0)) + clockwise = add_circle(world, 300, 350, 20, angular_velocity=10_000) + counter = add_circle(world, 600, 350, 20, angular_velocity=-10_000) + world.step(TIME_STEP) + assert clockwise.angular_velocity == pytest.approx(PHYSICS.max_angular_speed) + assert counter.angular_velocity == pytest.approx(-PHYSICS.max_angular_speed) + + +def test_emergency_brake_never_interferes_with_free_flight() -> None: + # Die Notbremse muss über der größten Eindringtiefe pro Schritt liegen, sonst würde sie + # normale Aufpralle verfälschen (Positionen verschieben und Energie hinzufügen). + assert PHYSICS.max_wall_penetration > PHYSICS.max_speed * PHYSICS.time_step + + +def test_emergency_brake_limits_deep_wall_penetration() -> None: + world = make_world(gravity=(0.0, 0.0)) + box = add_box(world, -10, 350, 20, 20) # steckt 30 px in der linken Wand + world.step(TIME_STEP) + assert box.bounds()[0] >= -PHYSICS.max_wall_penetration - 1e-9 + simulate(world, 0.5) + assert box.bounds()[0] >= -MAX_RESTING_OVERLAP + + +def test_fast_small_ball_does_not_tunnel_through_a_wall() -> None: + world = make_world(gravity=(0.0, 0.0)) + radius = MIN_BODY_SIZE + ball = add_circle(world, 500, 350, radius, velocity=(-50_000, -30_000), restitution=1.0) + for _ in range(round(3 / TIME_STEP)): + world.step(TIME_STEP) + allowed = PHYSICS.max_wall_penetration + 1e-9 + assert -allowed <= ball.position.x - radius + assert ball.position.x + radius <= WORLD_WIDTH + allowed + assert -allowed <= ball.position.y - radius + assert ball.position.y + radius <= WORLD_HEIGHT + allowed + + +def test_two_smallest_bodies_at_max_speed_do_not_pass_through_each_other() -> None: + world = make_world(gravity=(0.0, 0.0)) + radius = MIN_BODY_SIZE + speed = PHYSICS.max_speed + left = add_circle(world, 300, 350, radius, velocity=(speed, 0), restitution=1.0) + right = add_circle(world, 700, 350, radius, velocity=(-speed, 0), restitution=1.0) + simulate(world, 0.25) # Zusammenstoß nach ≈ 0,15 s + assert left.position.x < right.position.x + assert left.velocity.x < 0 < right.velocity.x + + +# --------------------------------------------------------------------- Schlafmodus + + +def test_sleeping_body_is_woken_by_an_impact() -> None: + world = make_world() + resting = add_box(world, 500, FLOOR - 20, 40, 20) + simulate(world, 1.5) + assert resting.sleeping + falling = add_circle(world, 500, 600, 15) # trifft die Box nach ≈ 0,3 s + simulate(world, 0.4) + assert not falling.sleeping + assert not resting.sleeping + + +def test_removing_a_support_wakes_the_body_on_top() -> None: + world = make_world() + support = add_box(world, 500, FLOOR - 20, 40, 20) + load = add_box(world, 500, FLOOR - 60, 30, 20) + simulate(world, 2.0) + assert support.sleeping + assert load.sleeping + world.remove_body(support) + assert not load.sleeping + simulate(world, 1.5) + assert load.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop) + + +def test_removing_a_body_only_wakes_sleeping_neighbours() -> None: + world = make_world() + removed = add_box(world, 300, FLOOR - 20, 40, 20) + neighbour = add_box(world, 360, FLOOR - 20, 20, 20) + far_away = add_box(world, 800, FLOOR - 20, 20, 20) + simulate(world, 2.0) + awake = add_circle(world, 300, 300, 15) # fällt noch, ist also wach + world.remove_body(removed) + assert not neighbour.sleeping + assert far_away.sleeping # weit weg: bleibt ungestört liegen + assert not awake.sleeping + assert removed not in world.bodies + + +def _is_supported(body: Body, world: World) -> bool: + """Liegt der Körper auf dem Boden oder auf einem tiefer liegenden Körper auf?""" + min_x, _, max_x, max_y = body.bounds() + if max_y >= world.height - 1.0: + return True + for other in world.bodies: + if other is body or other.position.y <= body.position.y: + continue # nur Körper darunter können tragen + other_min_x, other_min_y, other_max_x, _ = other.bounds() + overlaps_horizontally = other_min_x <= max_x and min_x <= other_max_x + if overlaps_horizontally and other_min_y <= max_y + 1.0: + return True + return False + + +def test_moving_the_base_of_a_sleeping_stack_wakes_the_whole_stack() -> None: + """Ein schlafender Stapel verliert seine Basis – die Kisten darüber fallen. + + Die unterste Kiste wird (wie beim Ziehen im Pausenmodus) ohne Physikschritt weggesetzt. + Die Kisten darüber dürfen danach nicht in der Luft weiterschlafen. + """ + world = make_world() + stack = [add_box(world, 500, FLOOR - 20 - 40 * i, 30, 20) for i in range(3)] + simulate(world, 2.0) + assert all(box.sleeping for box in stack) + stack[0].teleport(Vector2(150, FLOOR - 20)) + assert not any(box.sleeping for box in stack) + simulate(world, 2.0) + assert stack[1].position.y == pytest.approx(FLOOR - 20, abs=MAX_RESTING_OVERLAP) + assert stack[2].position.y == pytest.approx(FLOOR - 60, abs=2 * MAX_RESTING_OVERLAP) + + +def test_knocking_away_a_support_does_not_leave_bodies_floating() -> None: + """Wird die Unterlage eines schlafenden Körpers weggestoßen, fällt er herunter. + + Die flache Box verliert den Kontakt zur außermittig liegenden Kugel schon im ersten + Schritt nach dem Treffer. Ohne gemeinsames Wecken der Insel bliebe die Kugel schlafend + in der Luft hängen. + """ + world = make_world() + base = add_box(world, 500, FLOOR - 12, 40, 12) # flache Box am Boden + ball = add_circle(world, 530, FLOOR - 24 - 15, 15) # liegt 30 px außermittig darauf + simulate(world, 2.0) + assert base.sleeping + assert ball.sleeping + # Schwerer, flacher Rammbock trifft nur die Box, nicht die Kugel. + add_box(world, 800, FLOOR - 10, 20, 10, velocity=(-PHYSICS.max_speed, 0), density=5.0) + simulate(world, 2.0) + assert abs(ball.position.x - base.position.x) > 40 + 15 # Box ist unter der Kugel weg + assert ball.position.y == pytest.approx(FLOOR - 15, abs=MAX_RESTING_OVERLAP) + for body in world.bodies: + if body.sleeping: + assert _is_supported(body, world), f"{body} schläft in der Luft" + + +def test_toggling_gravity_wakes_everything() -> None: + world = make_world() + box = add_box(world, 500, FLOOR - 20, 40, 20) + simulate(world, 1.5) + assert box.sleeping + world.gravity_enabled = False + assert not box.sleeping + world.gravity_enabled = False # keine Änderung, kein erneutes Wecken nötig + assert not world.gravity_enabled + + +def test_held_bodies_ignore_gravity_and_never_sleep() -> None: + world = make_world() + ball = add_circle(world, 500, 300, 20) + ball.held = True + simulate(world, 2.0) + assert ball.position == Vector2(500, 300) + assert not ball.sleeping + + +def test_sleeping_can_be_disabled() -> None: + world = make_world(sleep_enabled=False) + box = add_box(world, 500, FLOOR - 20, 40, 20) + simulate(world, 3.0) + assert not box.sleeping + + +# --------------------------------------------------------------------- Verwaltung + + +def test_world_rejects_invalid_sizes_and_static_bodies() -> None: + with pytest.raises(ValueError, match="positive Größe"): + World(0, 100, PHYSICS) + world = make_world() + with pytest.raises(ValueError, match="Wände"): + world.add_body(Body.wall(Vector2(0, 1), 0, material())) + + +def test_body_at_returns_the_topmost_body() -> None: + world = make_world() + lower = add_box(world, 100, 100, 30, 30) + upper = add_circle(world, 110, 100, 20) + assert world.body_at(Vector2(110, 100)) is upper + assert world.body_at(Vector2(80, 80)) is lower + assert world.body_at(Vector2(300, 300)) is None + assert world.body_at(Vector2(133, 100), tolerance=5) is upper + + +def test_clamp_inside_keeps_the_whole_body_in_the_world() -> None: + world = make_world() + assert world.clamp_inside(Vector2(-50, 900), (20, 10)) == Vector2(20, WORLD_HEIGHT - 10) + inside = Vector2(300, 300) + assert world.clamp_inside(inside, (20, 10)) is inside + + +def test_clear_removes_all_bodies() -> None: + world = make_world() + for i in range(5): + add_circle(world, 100 + 50 * i, 600, 20) + simulate(world, 0.2) + world.clear() + assert world.bodies == [] + assert world.contacts == [] + simulate(world, 0.1) # auch eine leere Welt lässt sich weiter simulieren + + +def test_simulation_is_deterministic() -> None: + def run() -> list[tuple[float, float]]: + world = make_world() + rng = random.Random(5) + for _ in range(25): + add_circle(world, rng.uniform(50, 950), rng.uniform(50, 400), rng.uniform(12, 30)) + simulate(world, 2.0) + return [body.position.to_tuple() for body in world.bodies] + + assert run() == run() + + +@pytest.mark.slow +def test_hundred_bodies_stay_stable_for_a_thousand_steps() -> None: + world = make_world() + rng = random.Random(99) + for _ in range(100): + x, y = rng.uniform(40, 960), rng.uniform(40, 660) + velocity = (rng.uniform(-600, 600), rng.uniform(-600, 600)) + if rng.random() < 0.5: + add_circle(world, x, y, rng.uniform(12, 30), velocity=velocity) + else: + add_box(world, x, y, rng.uniform(12, 30), rng.uniform(12, 30), velocity=velocity) + + allowed = PHYSICS.max_wall_penetration + 1e-6 + for _ in range(1000): + world.step(TIME_STEP) + for body in world.bodies: + assert body.position.is_finite() + assert body.velocity.is_finite() + assert body.velocity.length() <= PHYSICS.max_speed + 1e-6 + min_x, min_y, max_x, max_y = body.bounds() + assert min_x >= -allowed and max_x <= WORLD_WIDTH + allowed + assert min_y >= -allowed and max_y <= WORLD_HEIGHT + allowed diff --git a/tests/test_architecture.py b/tests/test_architecture.py new file mode 100644 index 0000000..7050204 --- /dev/null +++ b/tests/test_architecture.py @@ -0,0 +1,60 @@ +"""Architektur-Tests: Die Physik-Schicht bleibt frei von pygame und fremden Bibliotheken. + +Anforderung: Die gesamte Physik darf nur das Standardmodul ``math`` verwenden +(zusätzlich ``typing``/``dataclasses`` für Typangaben und Datenstrukturen). Diese Tests +prüfen das statisch über den Syntaxbaum und zur Laufzeit in einem frischen Interpreter. +""" + +import ast +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src" / "sandbox" +PHYSICS_FILES = sorted((SOURCE_ROOT / "physics").glob("*.py")) +PURE_FILES = [*PHYSICS_FILES, SOURCE_ROOT / "config.py"] + +ALLOWED_STDLIB = {"__future__", "math", "typing", "dataclasses"} +ALLOWED_PROJECT = {"sandbox.config"} + + +def _imported_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + modules.add(node.module or "") + return modules + + +@pytest.mark.parametrize("path", PURE_FILES, ids=lambda path: path.name) +def test_physics_and_config_import_only_allowed_modules(path: Path) -> None: + forbidden = { + module + for module in _imported_modules(path) + if module not in ALLOWED_STDLIB + and module not in ALLOWED_PROJECT + and not module.startswith("sandbox.physics") + } + assert not forbidden, f"{path.name} importiert nicht erlaubte Module: {forbidden}" + + +def test_physics_files_were_found() -> None: + assert len(PHYSICS_FILES) >= 8 + + +def test_importing_the_physics_package_does_not_load_pygame() -> None: + code = "import sys, sandbox.physics, sandbox.config; print('pygame' in sys.modules)" + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=True, + env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT.parent)}, + ) + assert result.stdout.strip() == "False"