# Architektur ## Schichten Das Projekt besteht aus drei Schichten. Abhängigkeiten zeigen nur nach unten: ```mermaid flowchart TB subgraph APP["Anwendung (pygame-ce)"] main["main.py
App · Game-Loop"] input["input_handler.py
Maus/Tastatur → Aktionen"] renderer["renderer.py
Welt zeichnen"] ui["ui/
Sidebar · Widgets · Stil"] end subgraph TOOLS["Interaktion (ohne pygame)"] drag["interaction/drag.py
DragController"] tracker["interaction/velocity_tracker.py
VelocityTracker"] tools["interaction/tools.py
ToolSettings · BodyFactory"] demo["demo.py
Beispielszene"] viewport["viewport.py
Bildschirm ↔ Welt"] end subgraph PHYS["Physik (nur math)"] world["world.py
World"] solver["solver.py
ContactSolver"] collision["collision.py
Narrowphase"] broad["broadphase.py
Sweep-and-Prune"] sleep["sleep.py
Schlafmodus"] body["body.py · shapes.py
Body · Material · Formen"] vector["vector.py
Vector2"] timestep["timestep.py
FixedTimestep"] end config["config.py
alle Konstanten"] APP --> TOOLS APP --> PHYS TOOLS --> PHYS APP --> config TOOLS --> config PHYS --> config ``` | Schicht | Pakete/Module | Darf importieren | |---|---|---| | **Physik** | `sandbox.physics` | nur `math`, `typing`, `dataclasses`, `sandbox.config` | | **Interaktion** | `sandbox.interaction`, `sandbox.demo`, `sandbox.viewport` | Physik, Standardbibliothek | | **Anwendung** | `sandbox.main`, `sandbox.input_handler`, `sandbox.renderer`, `sandbox.ui` | alles, inklusive pygame | **Warum diese Trennung?** Die Physik soll ohne Grafik laufen und isoliert testbar sein. Das ist eine Anforderung, und `tests/test_architecture.py` prüft sie doppelt: statisch über den Syntaxbaum und zur Laufzeit in einem frischen Interpreter, in dem nach `import sandbox.physics` kein pygame geladen sein darf. Die Interaktionslogik – etwa die Glättung der Wurfgeschwindigkeit – arbeitet mit Weltkoordinaten und Zeitstempeln statt mit pygame-Events. So lässt auch sie sich ohne Fenster testen. `config.py` enthält ausschließlich Konstanten in unveränderlichen Dataclasses. Im Code gibt es keine "Magic Numbers". Tests variieren einzelne Werte mit `dataclasses.replace`. ## Module | Modul | Verantwortung | |---|---| | `physics/vector.py` | `Vector2`: unveränderlicher 2D-Vektor (Addition, Skalierung, Skalar- und Kreuzprodukt, Länge, Normalisierung …) | | `physics/shapes.py` | `Circle`, `Box` (AABB), `HalfPlane` (Wand): Fläche, Trägheitsmoment, Punkttest | | `physics/body.py` | `Material`, `Body`: Zustand, Masse = Fläche × Dichte, inverse Massen, Schlafzustand | | `physics/collision.py` | `Contact` und die Tests Kreis–Kreis, Kreis–Rechteck, Rechteck–Rechteck, Körper–Wand | | `physics/broadphase.py` | Sweep-and-Prune: Kandidatenpaare in O(n·log n + k) | | `physics/solver.py` | `ContactSolver`: Sequential Impulses (Normale, Reibung, Rollwiderstand), Warm-Starting, Positionskorrektur | | `physics/sleep.py` | Kontakt-Inseln (Union-Find), Einschlafen und Aufwecken | | `physics/timestep.py` | `FixedTimestep`: Frame-Zeit → Anzahl fester Schritte, Interpolationsgewicht | | `physics/world.py` | `World`: Körper und Wände verwalten, `step(dt)` orchestrieren | | `interaction/tools.py` | `ToolSettings` (Sidebar-Auswahl), `BodyFactory` (Körper erzeugen), `BodyStyle` (Farbe) | | `interaction/drag.py` | `DragController`: Greifen, per Geschwindigkeit ziehen, Werfen | | `interaction/velocity_tracker.py` | geglättete Mausgeschwindigkeit (Zeitfenster + exponentielle Glättung) | | `viewport.py` | Verschiebung zwischen Bildschirm- und Weltkoordinaten | | `input_handler.py` | Events verteilen: Sidebar zuerst, sonst Werkzeug-Aktion in der Welt; Tastenkürzel | | `ui/widgets.py` | selbst gezeichnete Widgets: `Label`, `Button`, `SegmentedControl`, `Slider`, `ColorPicker`, `SpawnTile` | | `ui/sidebar.py` | Aufbau und Layout der Werkzeugleiste | | `ui/style.py`, `ui/drawing.py` | Schriften, Text-Cache, Zeichenhelfer für Kreise und Rechtecke | | `renderer.py` | Welt zeichnen: Interpolation, Hervorhebung, Wurfpfeil, Vorschau, Debug-Ansicht | | `main.py` | `App`: alles verdrahten, Hauptschleife, Kommandozeile | | `demo.py` | Beispielszene für `--demo` | ## Datenfluss im Game-Loop Die Hauptschleife (`App.run`) ruft pro Bild `App.frame(frame_time)` auf: ```mermaid sequenceDiagram autonumber participant Loop as App.run participant App as App.frame participant Input as InputHandler participant Sidebar participant Drag as DragController participant Step as FixedTimestep participant World participant Draw as Renderer + Sidebar Loop->>App: frame_time (Clock.tick, max. 60 FPS) loop alle pygame-Events App->>Input: handle_event(event, zeit) Input->>Sidebar: Event anbieten alt Sidebar verbraucht es Sidebar-->>Input: True (Regler, Button, Kachel …) else Klick/Bewegung in der Welt Input->>Drag: grab / move / release Input->>World: Körper erzeugen / löschen end end App->>Input: update(zeit) – Mausposition an Tracker, Hover bestimmen App->>Step: advance(frame_time) Step-->>App: n feste Schritte (meist 2 × 1/120 s) loop n-mal App->>Drag: apply(dt) – Geschwindigkeit Richtung Maus App->>World: step(dt) end App->>Draw: Welt (interpoliert mit alpha), Sidebar, Vorschau App->>Loop: display.flip() ``` Wichtige Punkte: - **Entkopplung:** Die Bildrate (bis 60 FPS) und die Physik (fest 120 Hz) sind unabhängig. Der Akkumulator in `FixedTimestep` übersetzt zwischen beiden. Das Interpolationsgewicht `alpha` glättet die Darstellung. - **Ziehen im Physiktakt:** `DragController.apply` läuft vor *jedem* Physikschritt, nicht nur einmal pro Bild. Der gezogene Körper wird wie jeder andere vom Solver behandelt. - **Mausgeschwindigkeit pro Bild:** `InputHandler.update` meldet die Mausposition in jedem Frame an den Tracker, auch ohne Maus-Event. Nur so merkt er, dass die Maus stillsteht. - **Pause:** Keine Physikschritte. Ein gezogener Körper wird direkt versetzt (`DragController.place`). ## Ablauf eines Physikschritts Details und Formeln in [PHYSICS.md](PHYSICS.md#4-ablauf-eines-simulationsschritts): ```mermaid flowchart LR Z[Zustand merken] --> A[v += g·dt] --> B[Broadphase] --> C[Narrowphase] C --> D[Schlaf-Inseln
wecken] --> E[Wandkontakte] --> F[Solver:
Warm-Start +
8 Iterationen] F --> G[v begrenzen] --> H[x += v·dt] --> I[Positions-
korrektur] I --> K[Notbremse] --> J[Schlafmodus] ``` ## Entwurfsentscheidungen | Entscheidung | Begründung | |---|---| | `Vector2` ist unveränderlich (Zuweisung löst `AttributeError` aus) | Keine Aliasing-Fehler: Ein Körper kann seine Position herausgeben, ohne dass jemand sie über die geteilte Referenz verändert. | | Solver-Schleifen rechnen mit Floats statt mit `Vector2` | Sie laufen tausendfach pro Schritt. Objekt-Erzeugung ist in Python teuer; die Formeln sind komponentenweise identisch. | | Kontakte kanonisch geordnet (Kreis vor Rechteck vor Wand, sonst nach `id`) | Ein Körperpaar hat in jedem Schritt dieselbe Orientierung – Voraussetzung für das Warm-Starting. | | Wände sind statische `Body`-Objekte mit `HalfPlane`-Form | Der Solver behandelt Wandkontakte ohne Sonderfall (inverse Masse 0). Halbebenen sind tunnelsicher. | | Rechtecke drehen nie (Trägheitsmoment unendlich) | Die Aufgabe verlangt achsenparallele Rechtecke; jeder Kontakt braucht dann nur einen Punkt. | | Ziehen über Geschwindigkeit statt Teleport | Das gezogene Objekt kollidiert weiter und kann nicht durch Wände geschoben werden. | | Positionskorrektur per Projektion statt Baumgarte-Geschwindigkeit | Überlappende Objekte werden getrennt, ohne davongeschleudert zu werden. | | Schlafmodus über Kontakt-Inseln, die gemeinsam einschlafen und gemeinsam aufwachen | Verhindert das gegenseitige Aufwecken einzelner Körper und schlafende Körper, die nach dem Wegziehen ihrer Stütze in der Luft hängen. | | Abprallen aus der Geschwindigkeit vom Schrittbeginn | Sonst ginge die im selben Schritt addierte Schwerkraft in den Rückprall ein – ein elastischer Ball gewönne Höhe. | | Widgets fragen Zustände über Callbacks ab | Die Anzeige stimmt immer, auch wenn ein Wert per Tastenkürzel geändert wurde. | | Keine GUI-Bibliothek | pygame-ce genügt für die wenigen Widgets; weniger Abhängigkeiten. |