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

8.4 KiB
Raw Permalink Blame History

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

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.