Files
2026-09-24 15:45:31 +02:00

160 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. |