proejct commit
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Debug-Ansicht (`F1`): Kontaktpunkte (gelb), Kontaktnormalen (rot) und Kennzahlen:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ein aktuelles Bild der Demo-Szene erzeugst du jederzeit mit
|
||||||
|
`python -m sandbox --demo --screenshot docs/images/demo.png`.
|
||||||
@@ -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<br/>App · Game-Loop"]
|
||||||
|
input["input_handler.py<br/>Maus/Tastatur → Aktionen"]
|
||||||
|
renderer["renderer.py<br/>Welt zeichnen"]
|
||||||
|
ui["ui/<br/>Sidebar · Widgets · Stil"]
|
||||||
|
end
|
||||||
|
subgraph TOOLS["Interaktion (ohne pygame)"]
|
||||||
|
drag["interaction/drag.py<br/>DragController"]
|
||||||
|
tracker["interaction/velocity_tracker.py<br/>VelocityTracker"]
|
||||||
|
tools["interaction/tools.py<br/>ToolSettings · BodyFactory"]
|
||||||
|
demo["demo.py<br/>Beispielszene"]
|
||||||
|
viewport["viewport.py<br/>Bildschirm ↔ Welt"]
|
||||||
|
end
|
||||||
|
subgraph PHYS["Physik (nur math)"]
|
||||||
|
world["world.py<br/>World"]
|
||||||
|
solver["solver.py<br/>ContactSolver"]
|
||||||
|
collision["collision.py<br/>Narrowphase"]
|
||||||
|
broad["broadphase.py<br/>Sweep-and-Prune"]
|
||||||
|
sleep["sleep.py<br/>Schlafmodus"]
|
||||||
|
body["body.py · shapes.py<br/>Body · Material · Formen"]
|
||||||
|
vector["vector.py<br/>Vector2"]
|
||||||
|
timestep["timestep.py<br/>FixedTimestep"]
|
||||||
|
end
|
||||||
|
config["config.py<br/>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<br/>wecken] --> E[Wandkontakte] --> F[Solver:<br/>Warm-Start +<br/>8 Iterationen]
|
||||||
|
F --> G[v begrenzen] --> H[x += v·dt] --> I[Positions-<br/>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. |
|
||||||
@@ -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) |
|
||||||
+475
@@ -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<br/>für Interpolation] --> B[Geschwindigkeiten integrieren<br/>v += g·dt]
|
||||||
|
B --> C[Broadphase<br/>Sweep-and-Prune]
|
||||||
|
C --> D[Narrowphase<br/>Kontakte Körper–Körper]
|
||||||
|
D --> E[Berührte Schläfer wecken]
|
||||||
|
E --> F[Wandkontakte]
|
||||||
|
F --> G[Impulse lösen<br/>Normale · Reibung · Rollwiderstand]
|
||||||
|
G --> H[Geschwindigkeit begrenzen]
|
||||||
|
H --> I[Positionen integrieren<br/>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`) |
|
||||||
@@ -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.
|
||||||
+124
@@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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 .
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Ermöglicht den Start mit ``python -m sandbox``."""
|
||||||
|
|
||||||
|
from sandbox.main import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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})"
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 <sandbox.physics.body.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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Bedienoberfläche: Widgets und die Werkzeugleiste (Sidebar)."""
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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))),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Testsuite der Physik-Sandbox."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Headless-Tests für Oberfläche, Eingaben und Hauptschleife (SDL-Dummy-Treiber)."""
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests der pygame-freien Werkzeug-Logik."""
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests der pygame-freien Physik-Schicht."""
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user