8.4 KiB
Architektur
Schichten
Das Projekt besteht aus drei Schichten. Abhängigkeiten zeigen nur nach unten:
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:
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 Interpolationsgewichtalphaglättet die Darstellung. - Ziehen im Physiktakt:
DragController.applyläuft vor jedem Physikschritt, nicht nur einmal pro Bild. Der gezogene Körper wird wie jeder andere vom Solver behandelt. - Mausgeschwindigkeit pro Bild:
InputHandler.updatemeldet 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:
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. |