160 lines
8.4 KiB
Markdown
160 lines
8.4 KiB
Markdown
# 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. |
|