proejct commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""Physik-Sandbox: interaktive 2D-Physiksimulation mit eigener Physik-Engine.
|
||||
|
||||
Aufbau:
|
||||
|
||||
* :mod:`sandbox.physics` – reine Python-Physik (nur ``math``), ohne pygame testbar.
|
||||
* :mod:`sandbox.interaction` – Werkzeug-Logik (Erzeugen, Ziehen, Werfen), ebenfalls
|
||||
pygame-frei.
|
||||
* :mod:`sandbox.ui`, :mod:`sandbox.renderer`, :mod:`sandbox.input_handler`,
|
||||
:mod:`sandbox.main` – Fenster, Zeichnen, Eingaben und Hauptschleife mit pygame-ce.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# pygame-ce gibt beim ersten Import eine Werbezeile aus. Das Paket wird immer vor pygame
|
||||
# importiert, deshalb lässt sie sich hier zentral abschalten (ohne pygame zu importieren).
|
||||
os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Ermöglicht den Start mit ``python -m sandbox``."""
|
||||
|
||||
from sandbox.main import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Zentrale Konfiguration der Physik-Sandbox.
|
||||
|
||||
Alle einstellbaren Zahlenwerte des Projekts stehen in diesem Modul, damit der übrige Code
|
||||
frei von "Magic Numbers" bleibt. Zusammengehörige Werte sind in unveränderlichen
|
||||
Dataclasses gebündelt; Tests variieren einzelne Parameter mit :func:`dataclasses.replace`.
|
||||
|
||||
Einheiten: Längen in Pixeln (px), Zeiten in Sekunden (s), Geschwindigkeiten in px/s.
|
||||
Die y-Achse zeigt wie auf dem Bildschirm nach unten, die Schwerkraft wirkt also in +y.
|
||||
|
||||
Das Modul importiert ausschließlich die Standardbibliothek, weil auch die pygame-freie
|
||||
Physik-Schicht darauf zugreift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
# =============================================================================
|
||||
# Grundgrößen der Simulation
|
||||
# =============================================================================
|
||||
|
||||
#: Fester Physik-Zeitschritt in Sekunden (120 Hz), unabhängig von der Bildrate.
|
||||
TIME_STEP: Final = 1.0 / 120.0
|
||||
|
||||
#: Kleinste und größte Halbausdehnung eines Körpers in px (Kreisradius bzw. halbe
|
||||
#: Kantenlänge eines Rechtecks).
|
||||
MIN_BODY_SIZE: Final = 12.0
|
||||
MAX_BODY_SIZE: Final = 80.0
|
||||
|
||||
#: Sicherheitsabstand (< 1) zur theoretischen Tunnel-Grenze, siehe MAX_SPEED.
|
||||
TUNNELING_SAFETY_FACTOR: Final = 0.9
|
||||
|
||||
#: Geschwindigkeitsobergrenze in px/s (≈ 1296 px/s).
|
||||
#:
|
||||
#: Warum: Die Kollisionserkennung ist diskret, sie sieht nur die Positionen am Ende jedes
|
||||
#: Schritts. Fliegen zwei der kleinsten Körper frontal aufeinander zu, darf ihre
|
||||
#: Relativbewegung pro Schritt (2·v·dt) die Summe ihrer Radien (2·MIN_BODY_SIZE) nicht
|
||||
#: überschreiten – sonst springen die Mittelpunkte aneinander vorbei ("Tunneln").
|
||||
#: Daraus folgt v < MIN_BODY_SIZE / dt. Die Wände sind Halbebenen und damit ohnehin
|
||||
#: tunnelsicher: Ein Körper "hinter" der Wand wird immer als Durchdringung erkannt.
|
||||
MAX_SPEED: Final = TUNNELING_SAFETY_FACTOR * MIN_BODY_SIZE / TIME_STEP
|
||||
|
||||
#: Obergrenze der Winkelgeschwindigkeit in rad/s: So schnell dreht sich der kleinste Kreis,
|
||||
#: wenn er mit MAX_SPEED rollt (ω = v / r). Verhindert numerisch explodierende Drehungen.
|
||||
MAX_ANGULAR_SPEED: Final = MAX_SPEED / MIN_BODY_SIZE
|
||||
|
||||
#: Toleranz für Gleitkomma-Vergleiche, z. B. "Vektor hat Länge 0".
|
||||
NUMERIC_EPSILON: Final = 1e-9
|
||||
|
||||
#: Umrechnung Sekunden → Millisekunden (pygame misst Frame-Zeiten in ms).
|
||||
MILLISECONDS_PER_SECOND: Final = 1000.0
|
||||
|
||||
#: Standard-Materialwerte für neue Körper.
|
||||
DEFAULT_DENSITY: Final = 1.0
|
||||
DEFAULT_RESTITUTION: Final = 0.5
|
||||
DEFAULT_FRICTION: Final = 0.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhysicsConfig:
|
||||
"""Parameter der Physik-Engine.
|
||||
|
||||
Attributes:
|
||||
gravity: Schwerkraftbeschleunigung (x, y) in px/s². 980 px/s² entspricht 9,8 m/s²
|
||||
bei einem Maßstab von 100 px pro Meter.
|
||||
time_step: Fester Zeitschritt in s.
|
||||
velocity_iterations: Durchläufe des Impuls-Solvers pro Schritt. Mehr Durchläufe
|
||||
lassen Stapel steifer werden, kosten aber Rechenzeit.
|
||||
position_iterations: Durchläufe der Positionskorrektur pro Schritt.
|
||||
restitution_threshold: Minimale Aufprallgeschwindigkeit in px/s, ab der Körper
|
||||
abprallen. Darunter wird der Stoß als unelastisch behandelt, damit aufliegende
|
||||
Körper nicht durch winzige Dauer-Hüpfer zittern.
|
||||
penetration_slop: Geduldete Eindringtiefe in px. Wird nicht korrigiert, damit der
|
||||
Kontakt zu einer Unterlage im nächsten Schritt sicher wieder erkannt wird –
|
||||
sonst wechselten "Kontakt" und "kein Kontakt" ständig ab (Zittern).
|
||||
position_correction_factor: Anteil der Eindringtiefe, der pro Durchlauf
|
||||
korrigiert wird. Werte < 1 verteilen die Korrektur weich auf mehrere Schritte.
|
||||
max_position_correction: Obergrenze der Korrektur pro Kontakt und Durchlauf in px.
|
||||
Stark überlappende Körper (z. B. frisch erzeugt) werden so sanft getrennt.
|
||||
max_speed: Geschwindigkeitsobergrenze in px/s (siehe MAX_SPEED).
|
||||
max_angular_speed: Obergrenze der Winkelgeschwindigkeit in rad/s.
|
||||
linear_damping: Lineare Dämpfung (Luftwiderstand) in 1/s.
|
||||
angular_damping: Dämpfung der Drehung (Luftwiderstand) in 1/s.
|
||||
rolling_resistance: Rollwiderstandsbeiwert μ_r (dimensionslos). An jedem Kontakt
|
||||
bremst ein Moment von höchstens μ_r · Normalkraft · Radius die Drehung. Ohne
|
||||
Rollwiderstand würde ein rollender Kreis nie anhalten, weil reines Rollen keine
|
||||
Reibungsenergie verbraucht; ein Rechteck auf einem Kreis führe darauf wie auf
|
||||
einer Rolle davon.
|
||||
warm_starting: Startet den Solver mit den Impulsen des vorigen Schritts.
|
||||
warm_start_min_alignment: Minimales Skalarprodukt zwischen alter und neuer
|
||||
Kontaktnormale, damit der alte Impuls wiederverwendet wird. Dreht sich die
|
||||
Normale stärker, passt der alte Impuls nicht mehr.
|
||||
sleep_enabled: Schaltet den Schlafmodus für ruhende Körper ein.
|
||||
sleep_linear_speed: Unterhalb dieser Geschwindigkeit (px/s) gilt ein Körper als
|
||||
ruhend.
|
||||
sleep_angular_speed: Unterhalb dieser Winkelgeschwindigkeit (rad/s) gilt ein Körper
|
||||
als ruhend.
|
||||
time_to_sleep: So lange (s) muss eine ganze Kontaktgruppe ruhen, bevor sie einschläft.
|
||||
max_wall_penetration: Notbremse in px: Weiter darf ein Körper nie in eine Wand
|
||||
ragen. Der Wert liegt über der größten Eindringtiefe, die ein frei fliegender
|
||||
Körper in einem Schritt erreichen kann (MAX_SPEED · dt ≈ 10,8 px). Die Grenze
|
||||
greift deshalb nie bei normalen Aufprallen – die löst der Solver –, sondern nur,
|
||||
wenn ein Körper z. B. von einem gezogenen Objekt gegen die Wand gequetscht wird.
|
||||
wall_restitution: Stoßzahl der Wände. 0 bedeutet: Beim Mischen mit max(e_A, e_B)
|
||||
bestimmt allein das Material des Körpers, wie stark er abprallt.
|
||||
wall_friction: Reibungskoeffizient der Wände.
|
||||
"""
|
||||
|
||||
gravity: tuple[float, float] = (0.0, 980.0)
|
||||
time_step: float = TIME_STEP
|
||||
velocity_iterations: int = 8
|
||||
position_iterations: int = 2
|
||||
restitution_threshold: float = 50.0
|
||||
penetration_slop: float = 0.5
|
||||
position_correction_factor: float = 0.4
|
||||
max_position_correction: float = 6.0
|
||||
max_speed: float = MAX_SPEED
|
||||
max_angular_speed: float = MAX_ANGULAR_SPEED
|
||||
linear_damping: float = 0.0
|
||||
angular_damping: float = 0.0
|
||||
rolling_resistance: float = 0.1
|
||||
warm_starting: bool = True
|
||||
warm_start_min_alignment: float = 0.95
|
||||
sleep_enabled: bool = True
|
||||
sleep_linear_speed: float = 4.0
|
||||
sleep_angular_speed: float = 0.1
|
||||
time_to_sleep: float = 0.5
|
||||
max_wall_penetration: float = MIN_BODY_SIZE
|
||||
wall_restitution: float = 0.0
|
||||
wall_friction: float = 0.6
|
||||
|
||||
|
||||
#: Standardkonfiguration der Physik.
|
||||
PHYSICS: Final = PhysicsConfig()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fenster und Hauptschleife
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowConfig:
|
||||
"""Fenster und Hauptschleife.
|
||||
|
||||
Attributes:
|
||||
width: Fensterbreite in px.
|
||||
height: Fensterhöhe in px.
|
||||
title: Fenstertitel.
|
||||
sidebar_width: Breite der Werkzeugleiste am linken Rand in px. Rechts davon
|
||||
beginnt die Physikwelt.
|
||||
fps_limit: Obergrenze der Bildrate. 60 Bilder/s genügen für flüssige Bewegung; die
|
||||
Physik läuft unabhängig davon mit 120 Hz.
|
||||
max_steps_per_frame: Höchstens so viele Physikschritte pro Bild (Schutz vor der
|
||||
"Spiral of Death", siehe :class:`~sandbox.physics.timestep.FixedTimestep`).
|
||||
stats_smoothing: Glättungsfaktor (0..1) der FPS-/Rechenzeit-Anzeige pro Bild.
|
||||
screenshot_delay: Standard-Laufzeit (s) vor einem Bildschirmfoto mit
|
||||
``--screenshot``.
|
||||
"""
|
||||
|
||||
width: int = 1280
|
||||
height: int = 720
|
||||
title: str = "Physik-Sandbox"
|
||||
sidebar_width: int = 260
|
||||
fps_limit: int = 60
|
||||
max_steps_per_frame: int = 8
|
||||
stats_smoothing: float = 0.1
|
||||
screenshot_delay: float = 3.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Werkzeuge und Interaktion
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InteractionConfig:
|
||||
"""Erzeugen, Ziehen und Werfen von Körpern.
|
||||
|
||||
Attributes:
|
||||
drag_response_rate: Wie schnell ein gezogener Körper der Maus folgt (1/s). Pro
|
||||
Physikschritt wird der Anteil ``rate·dt`` des Abstands zum Ziel überbrückt – bei
|
||||
60/s und dt = 1/120 s also die Hälfte. Werte > 1/dt würden überschwingen.
|
||||
throw_window: Zeitfenster (s) der Mausbewegung, aus dem die Wurfgeschwindigkeit
|
||||
gemittelt wird ("die letzten Frames").
|
||||
throw_smoothing_time: Zeitkonstante (s) der zusätzlichen exponentiellen Glättung.
|
||||
max_throw_speed: Obergrenze der Wurfgeschwindigkeit in px/s.
|
||||
pick_tolerance: Zusätzlicher Rand (px), mit dem kleine Körper leichter zu greifen
|
||||
sind.
|
||||
max_bodies: Höchstzahl gleichzeitiger Körper (schützt die Bildrate).
|
||||
size_wheel_step: Größenänderung (px) pro Mausrad-Raster.
|
||||
min_size: Kleinste wählbare Größe (Radius bzw. halbe Kantenlänge) in px.
|
||||
max_size: Größte wählbare Größe in px.
|
||||
size_step: Rasterung des Größenreglers in px.
|
||||
min_aspect_ratio: Kleinstes Seitenverhältnis Breite:Höhe eines Rechtecks.
|
||||
max_aspect_ratio: Größtes Seitenverhältnis Breite:Höhe eines Rechtecks.
|
||||
aspect_ratio_step: Rasterung des Seitenverhältnis-Reglers.
|
||||
min_density: Kleinste wählbare Dichte.
|
||||
max_density: Größte wählbare Dichte.
|
||||
density_step: Rasterung des Dichte-Reglers.
|
||||
max_restitution: Größte wählbare Elastizität.
|
||||
restitution_step: Rasterung des Elastizitäts-Reglers.
|
||||
default_size: Startwert der Größe in px.
|
||||
default_aspect_ratio: Startwert des Seitenverhältnisses.
|
||||
default_density: Startwert der Dichte.
|
||||
default_restitution: Startwert der Elastizität.
|
||||
friction: Reibungskoeffizient neu erzeugter Körper.
|
||||
"""
|
||||
|
||||
drag_response_rate: float = 60.0
|
||||
throw_window: float = 0.1
|
||||
throw_smoothing_time: float = 0.05
|
||||
max_throw_speed: float = MAX_SPEED
|
||||
pick_tolerance: float = 4.0
|
||||
max_bodies: int = 300
|
||||
size_wheel_step: float = 2.0
|
||||
min_size: float = MIN_BODY_SIZE
|
||||
max_size: float = MAX_BODY_SIZE
|
||||
size_step: float = 1.0
|
||||
min_aspect_ratio: float = 0.5
|
||||
max_aspect_ratio: float = 2.0
|
||||
aspect_ratio_step: float = 0.05
|
||||
min_density: float = 0.2
|
||||
max_density: float = 5.0
|
||||
density_step: float = 0.1
|
||||
max_restitution: float = 1.0
|
||||
restitution_step: float = 0.05
|
||||
default_size: float = 28.0
|
||||
default_aspect_ratio: float = 1.5
|
||||
default_density: float = DEFAULT_DENSITY
|
||||
default_restitution: float = DEFAULT_RESTITUTION
|
||||
friction: float = DEFAULT_FRICTION
|
||||
|
||||
|
||||
#: Standardkonfiguration der Interaktion.
|
||||
INTERACTION: Final = InteractionConfig()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Darstellung
|
||||
# =============================================================================
|
||||
|
||||
#: RGB-Farbe.
|
||||
Color = tuple[int, int, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Theme:
|
||||
"""Farben der Oberfläche (dunkles Farbschema).
|
||||
|
||||
Attributes:
|
||||
world_background: Hintergrund der Physikwelt.
|
||||
grid: Hilfsgitter der Welt.
|
||||
world_border: Rahmen um die Welt.
|
||||
sidebar_background: Hintergrund der Werkzeugleiste.
|
||||
panel: Grundfarbe von Buttons und Reglern.
|
||||
panel_hover: Button unter der Maus.
|
||||
panel_active: Aktiver Button bzw. gewählte Option.
|
||||
accent: Akzentfarbe (Reglerfüllung, aktive Umrandung).
|
||||
text: Normaler Text.
|
||||
text_dim: Nebensächlicher Text.
|
||||
danger: Farbe für "Alles löschen".
|
||||
highlight: Umrandung des Körpers unter der Maus.
|
||||
held_outline: Umrandung des gezogenen Körpers.
|
||||
throw_arrow: Pfeil der aktuellen Wurfgeschwindigkeit.
|
||||
preview_alpha: Deckkraft (0–255) der Vorschau beim Erzeugen.
|
||||
outline_shade: Faktor, um den die Umrandung dunkler ist als die Füllung.
|
||||
debug_contact: Kontaktpunkte in der Debug-Ansicht.
|
||||
debug_normal: Kontaktnormalen in der Debug-Ansicht.
|
||||
debug_sleeping: Umrandung schlafender Körper in der Debug-Ansicht.
|
||||
body_palette: Auswahl an Körperfarben.
|
||||
"""
|
||||
|
||||
world_background: Color = (22, 25, 32)
|
||||
grid: Color = (32, 36, 46)
|
||||
world_border: Color = (64, 71, 88)
|
||||
sidebar_background: Color = (15, 17, 22)
|
||||
panel: Color = (31, 35, 45)
|
||||
panel_hover: Color = (42, 47, 60)
|
||||
panel_active: Color = (48, 72, 102)
|
||||
accent: Color = (96, 176, 240)
|
||||
text: Color = (226, 230, 239)
|
||||
text_dim: Color = (136, 145, 164)
|
||||
danger: Color = (228, 92, 92)
|
||||
highlight: Color = (255, 255, 255)
|
||||
held_outline: Color = (255, 214, 102)
|
||||
throw_arrow: Color = (255, 214, 102)
|
||||
preview_alpha: int = 110
|
||||
outline_shade: float = 0.6
|
||||
debug_contact: Color = (255, 214, 10)
|
||||
debug_normal: Color = (255, 110, 64)
|
||||
debug_sleeping: Color = (120, 130, 150)
|
||||
body_palette: tuple[Color, ...] = (
|
||||
(239, 83, 80),
|
||||
(255, 167, 38),
|
||||
(255, 213, 79),
|
||||
(102, 187, 106),
|
||||
(38, 198, 218),
|
||||
(66, 133, 244),
|
||||
(171, 71, 188),
|
||||
(236, 64, 122),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UiConfig:
|
||||
"""Schriften, Abstände und Maße der Oberfläche.
|
||||
|
||||
Attributes:
|
||||
font_names: Bevorzugte Systemschriften (die erste vorhandene wird genutzt).
|
||||
title_font_size: Schriftgröße des Titels.
|
||||
label_font_size: Schriftgröße von Beschriftungen.
|
||||
small_font_size: Schriftgröße von Hinweisen und Werten.
|
||||
padding: Innenabstand der Sidebar in px.
|
||||
section_gap: Abstand zwischen Abschnitten in px.
|
||||
row_gap: Abstand zwischen Elementen eines Abschnitts in px.
|
||||
header_height: Höhe einer Abschnittsüberschrift in px.
|
||||
button_height: Höhe von Buttons in px.
|
||||
slider_height: Höhe eines Reglers (Beschriftung + Schiene) in px.
|
||||
slider_track_height: Dicke der Reglerschiene in px.
|
||||
slider_knob_radius: Radius des Reglerknopfs in px.
|
||||
swatch_size: Kantenlänge eines Farbfelds in px.
|
||||
swatch_gap: Abstand zwischen Farbfeldern in px.
|
||||
spawn_tile_height: Höhe der Vorschau-Kachel in px.
|
||||
corner_radius: Eckenradius von Buttons und Kacheln in px.
|
||||
border_width: Linienstärke von Rahmen (Buttons, Umschalter, Weltrand) in px.
|
||||
selection_ring_gap: Abstand des Auswahlrings um das gewählte Farbfeld in px.
|
||||
selection_ring_width: Linienstärke des Auswahlrings in px.
|
||||
grid_spacing: Abstand der Gitterlinien der Welt in px.
|
||||
outline_width: Linienstärke von Körperumrandungen in px.
|
||||
highlight_width: Linienstärke der Hervorhebung in px.
|
||||
throw_arrow_time: Länge des Wurfpfeils als Flugzeit in s (Pfeil = v · Zeit).
|
||||
debug_normal_length: Länge der gezeichneten Kontaktnormalen in px.
|
||||
debug_contact_radius: Radius der gezeichneten Kontaktpunkte in px.
|
||||
preview_scale_limit: Höchster Anteil der Kachelhöhe, den die Vorschau einnimmt.
|
||||
marker_length: Länge des Drehmarkers eines Kreises als Anteil des Radius.
|
||||
text_cache_size: So viele gerenderte Texte werden höchstens zwischengespeichert.
|
||||
"""
|
||||
|
||||
font_names: tuple[str, ...] = ("segoeui", "helveticaneue", "dejavusans", "arial")
|
||||
title_font_size: int = 22
|
||||
label_font_size: int = 15
|
||||
small_font_size: int = 13
|
||||
padding: int = 16
|
||||
section_gap: int = 12
|
||||
row_gap: int = 6
|
||||
header_height: int = 20
|
||||
button_height: int = 32
|
||||
slider_height: int = 38
|
||||
slider_track_height: int = 4
|
||||
slider_knob_radius: int = 7
|
||||
swatch_size: int = 22
|
||||
swatch_gap: int = 3
|
||||
spawn_tile_height: int = 92
|
||||
corner_radius: int = 6
|
||||
border_width: int = 1
|
||||
selection_ring_gap: int = 2
|
||||
selection_ring_width: int = 2
|
||||
grid_spacing: int = 40
|
||||
outline_width: int = 2
|
||||
highlight_width: int = 3
|
||||
throw_arrow_time: float = 0.15
|
||||
debug_normal_length: float = 18.0
|
||||
debug_contact_radius: int = 3
|
||||
preview_scale_limit: float = 0.8
|
||||
marker_length: float = 0.85
|
||||
text_cache_size: int = 512
|
||||
|
||||
|
||||
#: Tastenbelegung: Tastenname (wie in ``pygame.key.key_code``) → Aktion.
|
||||
KEY_BINDINGS: Final = (
|
||||
("space", "toggle_pause"),
|
||||
("g", "toggle_gravity"),
|
||||
("c", "clear"),
|
||||
("delete", "delete_hovered"),
|
||||
("1", "shape_circle"),
|
||||
("2", "shape_box"),
|
||||
("f1", "toggle_debug"),
|
||||
("escape", "quit"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoSceneConfig:
|
||||
"""Aufbau der Beispielszene (Start mit ``--demo``).
|
||||
|
||||
x- und y-Positionen sind Anteile der Weltbreite bzw. -höhe, damit die Szene in jede
|
||||
Fenstergröße passt.
|
||||
|
||||
Attributes:
|
||||
tower_x: Position des Rechteckturms.
|
||||
tower_levels: Anzahl der Rechtecke im Turm.
|
||||
box_size: Größe der Turm-Rechtecke in px.
|
||||
box_aspect_ratio: Seitenverhältnis der Turm-Rechtecke.
|
||||
pyramid_x: Mitte der Kreispyramide.
|
||||
pyramid_rows: Anzahl der Reihen der Pyramide.
|
||||
pyramid_circle_size: Radius der Pyramidenkreise in px.
|
||||
pyramid_density: Dichte der Pyramidenkreise (schwer, damit sie stabil liegen).
|
||||
pyramid_row_height: Vertikaler Reihenabstand in Radien. Dichteste Packung wäre
|
||||
√3 ≈ 1,732; etwas mehr lässt einen hauchdünnen Spalt, damit nichts überlappt.
|
||||
balls_x: Position des ersten Gummiballs.
|
||||
balls_y: Starthöhe der Gummibälle.
|
||||
ball_count: Anzahl der Gummibälle.
|
||||
ball_size: Radius der Gummibälle in px.
|
||||
ball_spacing: Abstand der Bälle in Radien.
|
||||
ball_restitution: Elastizität der Gummibälle.
|
||||
"""
|
||||
|
||||
tower_x: float = 0.78
|
||||
tower_levels: int = 7
|
||||
box_size: float = 22.0
|
||||
box_aspect_ratio: float = 2.0
|
||||
pyramid_x: float = 0.45
|
||||
pyramid_rows: int = 5
|
||||
pyramid_circle_size: float = 20.0
|
||||
pyramid_density: float = 3.0
|
||||
pyramid_row_height: float = 1.75
|
||||
balls_x: float = 0.15
|
||||
balls_y: float = 0.2
|
||||
ball_count: int = 4
|
||||
ball_size: float = 16.0
|
||||
ball_spacing: float = 2.4
|
||||
ball_restitution: float = 0.8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppConfig:
|
||||
"""Gesamtkonfiguration der Anwendung."""
|
||||
|
||||
window: WindowConfig = WindowConfig()
|
||||
physics: PhysicsConfig = PhysicsConfig()
|
||||
interaction: InteractionConfig = InteractionConfig()
|
||||
theme: Theme = Theme()
|
||||
ui: UiConfig = UiConfig()
|
||||
demo: DemoSceneConfig = DemoSceneConfig()
|
||||
|
||||
|
||||
#: Standardkonfiguration der Anwendung.
|
||||
APP: Final = AppConfig()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Beispielszene zum Ausprobieren (Start mit ``python -m sandbox --demo``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from sandbox.config import DemoSceneConfig
|
||||
from sandbox.interaction import BodyFactory, ShapeKind, ToolSettings
|
||||
from sandbox.physics import Vector2
|
||||
|
||||
|
||||
def build_demo_scene(factory: BodyFactory, base: ToolSettings, scene: DemoSceneConfig) -> None:
|
||||
"""Baut einen Turm aus Rechtecken, eine Kreispyramide und ein paar Gummibälle auf.
|
||||
|
||||
Args:
|
||||
factory: Erzeugt die Körper (und fügt sie der Welt hinzu).
|
||||
base: Ausgangseinstellungen (Farbe, Material), die je Gruppe abgewandelt werden.
|
||||
scene: Aufbau der Szene.
|
||||
"""
|
||||
world = factory.world
|
||||
floor = world.height
|
||||
|
||||
boxes = replace(
|
||||
base, shape=ShapeKind.BOX, size=scene.box_size, aspect_ratio=scene.box_aspect_ratio
|
||||
)
|
||||
_, half_height = boxes.half_extents()
|
||||
for level in range(scene.tower_levels):
|
||||
y = floor - half_height - level * 2 * half_height
|
||||
factory.create(boxes, Vector2(world.width * scene.tower_x, y))
|
||||
|
||||
circles = replace(
|
||||
base,
|
||||
shape=ShapeKind.CIRCLE,
|
||||
size=scene.pyramid_circle_size,
|
||||
density=scene.pyramid_density,
|
||||
)
|
||||
radius = circles.size
|
||||
rows = scene.pyramid_rows
|
||||
for row in range(rows):
|
||||
for column in range(rows - row):
|
||||
# Jede Reihe ist um einen Radius eingerückt und liegt in den Mulden der unteren.
|
||||
x = world.width * scene.pyramid_x + (2 * column + row - rows + 1) * radius
|
||||
y = floor - radius - row * radius * scene.pyramid_row_height
|
||||
factory.create(circles, Vector2(x, y))
|
||||
|
||||
balls = replace(
|
||||
base, shape=ShapeKind.CIRCLE, size=scene.ball_size, restitution=scene.ball_restitution
|
||||
)
|
||||
for index in range(scene.ball_count):
|
||||
x = world.width * scene.balls_x + index * scene.ball_spacing * scene.ball_size
|
||||
factory.create(balls, Vector2(x, world.height * scene.balls_y))
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Übersetzt pygame-Eingaben in Aktionen von Sidebar, Welt und Werkzeugen.
|
||||
|
||||
Maus-Events werden zuerst der Sidebar angeboten. Was sie nicht verbraucht und in der Welt
|
||||
liegt, wird zur Werkzeug-Aktion: Linksklick auf einen Körper greift ihn, Linksklick ins
|
||||
Leere erzeugt einen neuen Körper (und greift ihn sofort, sodass man ihn direkt werfen
|
||||
kann), Rechtsklick löscht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import Protocol
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import KEY_BINDINGS, InteractionConfig
|
||||
from sandbox.interaction import BodyFactory, DragController, ShapeKind, ToolSettings
|
||||
from sandbox.physics import Body, Vector2, World
|
||||
from sandbox.renderer import SpawnPreview
|
||||
from sandbox.ui.sidebar import Sidebar, SidebarControls
|
||||
from sandbox.viewport import Viewport
|
||||
|
||||
|
||||
class AppControls(SidebarControls, Protocol):
|
||||
"""Aktionen der Anwendung, die per Tastatur ausgelöst werden können."""
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Beendet die Anwendung nach dem aktuellen Frame."""
|
||||
...
|
||||
|
||||
def toggle_debug(self) -> None:
|
||||
"""Schaltet die Debug-Ansicht um."""
|
||||
...
|
||||
|
||||
|
||||
class InputHandler:
|
||||
"""Verteilt Maus- und Tastatur-Events.
|
||||
|
||||
Args:
|
||||
world: Die Physikwelt.
|
||||
viewport: Lage der Welt auf dem Bildschirm.
|
||||
drag: Steuerung für Greifen und Werfen.
|
||||
factory: Erzeugt neue Körper.
|
||||
tools: Aktuelle Werkzeug-Einstellungen.
|
||||
config: Interaktionsparameter.
|
||||
controls: Aktionen der Anwendung.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
world: World,
|
||||
viewport: Viewport,
|
||||
drag: DragController,
|
||||
factory: BodyFactory,
|
||||
tools: ToolSettings,
|
||||
config: InteractionConfig,
|
||||
controls: AppControls,
|
||||
) -> None:
|
||||
self.world = world
|
||||
self.viewport = viewport
|
||||
self.drag = drag
|
||||
self.factory = factory
|
||||
self.tools = tools
|
||||
self.config = config
|
||||
self.controls = controls
|
||||
self.sidebar: Sidebar | None = None
|
||||
self.mouse_pos: tuple[int, int] = pygame.mouse.get_pos()
|
||||
self.hovered_body: Body | None = None
|
||||
self.spawn_drag_active = False
|
||||
self._cursor: int | None = None
|
||||
actions: dict[str, Callable[[], None]] = {
|
||||
"toggle_pause": controls.toggle_pause,
|
||||
"toggle_gravity": controls.toggle_gravity,
|
||||
"clear": controls.clear_world,
|
||||
"delete_hovered": self.delete_hovered,
|
||||
"shape_circle": partial(setattr, tools, "shape", ShapeKind.CIRCLE),
|
||||
"shape_box": partial(setattr, tools, "shape", ShapeKind.BOX),
|
||||
"toggle_debug": controls.toggle_debug,
|
||||
"quit": controls.request_quit,
|
||||
}
|
||||
self._key_actions = {
|
||||
pygame.key.key_code(key): actions[action] for key, action in KEY_BINDINGS
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ Abfragen
|
||||
|
||||
@property
|
||||
def pointer_in_world(self) -> bool:
|
||||
"""Ob die Maus über der Welt steht."""
|
||||
return self.viewport.contains(self.mouse_pos)
|
||||
|
||||
@property
|
||||
def world_pointer(self) -> Vector2:
|
||||
"""Mausposition in Weltkoordinaten."""
|
||||
return self.viewport.to_world(self.mouse_pos)
|
||||
|
||||
def spawn_preview(self) -> SpawnPreview | None:
|
||||
"""Vorschau des Objekts, das ein Klick an der aktuellen Mausposition erzeugen würde."""
|
||||
half_extents = self.tools.half_extents()
|
||||
if self.spawn_drag_active:
|
||||
center = (float(self.mouse_pos[0]), float(self.mouse_pos[1]))
|
||||
elif (
|
||||
self.pointer_in_world
|
||||
and self.drag.body is None
|
||||
and self.hovered_body is None
|
||||
and not self.factory.is_full
|
||||
):
|
||||
clamped = self.world.clamp_inside(self.world_pointer, half_extents)
|
||||
center = self.viewport.to_screen(clamped)
|
||||
else:
|
||||
return None
|
||||
color = self.factory.color_for(self.tools)
|
||||
return SpawnPreview(self.tools.shape, half_extents, color, center)
|
||||
|
||||
# ------------------------------------------------------------------ Aktionen
|
||||
|
||||
def begin_spawn_drag(self) -> None:
|
||||
"""Beginnt das Herausziehen eines neuen Objekts aus der Sidebar."""
|
||||
self.spawn_drag_active = True
|
||||
|
||||
def delete_hovered(self) -> None:
|
||||
"""Löscht den Körper unter der Maus."""
|
||||
body = self._body_under_pointer()
|
||||
if body is not None:
|
||||
self.delete(body)
|
||||
|
||||
def delete(self, body: Body) -> None:
|
||||
"""Entfernt einen Körper (und lässt ihn vorher los, falls er gehalten wird)."""
|
||||
if self.drag.body is body:
|
||||
self.drag.cancel()
|
||||
if self.hovered_body is body:
|
||||
self.hovered_body = None
|
||||
self.world.remove_body(body)
|
||||
|
||||
def forget_bodies(self) -> None:
|
||||
"""Vergisst Verweise auf Körper (nach "Alles löschen")."""
|
||||
self.drag.cancel()
|
||||
self.hovered_body = None
|
||||
self.spawn_drag_active = False
|
||||
|
||||
# ------------------------------------------------------------------ Events
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, time: float) -> None:
|
||||
"""Verarbeitet ein pygame-Event.
|
||||
|
||||
Args:
|
||||
event: Das Event.
|
||||
time: Aktuelle Anwendungszeit in s (für die Wurfgeschwindigkeit).
|
||||
"""
|
||||
if event.type == pygame.QUIT:
|
||||
self.controls.request_quit()
|
||||
elif event.type == pygame.KEYDOWN:
|
||||
action = self._key_actions.get(event.key)
|
||||
if action is not None:
|
||||
action()
|
||||
elif event.type == pygame.MOUSEBUTTONDOWN:
|
||||
self.mouse_pos = event.pos
|
||||
if not self._offer_to_sidebar(event):
|
||||
self._press_in_world(event.button, time)
|
||||
elif event.type == pygame.MOUSEMOTION:
|
||||
self.mouse_pos = event.pos
|
||||
if not self._offer_to_sidebar(event):
|
||||
self._move(time)
|
||||
elif event.type == pygame.MOUSEBUTTONUP:
|
||||
self.mouse_pos = event.pos
|
||||
if event.button == pygame.BUTTON_LEFT:
|
||||
# Loslassen beendet ein Ziehen immer – auch über der Sidebar.
|
||||
self.drag.release(paused=self.controls.is_paused())
|
||||
self.spawn_drag_active = False
|
||||
self._offer_to_sidebar(event)
|
||||
elif event.type == pygame.MOUSEWHEEL and not self._offer_to_sidebar(event):
|
||||
self._change_size(event.y)
|
||||
|
||||
def update(self, time: float) -> None:
|
||||
"""Einmal pro Frame: Mausposition an den Wurf-Tracker melden, Hover bestimmen.
|
||||
|
||||
Die Position wird auch ohne Maus-Event gemeldet: Steht die Maus still, kommen keine
|
||||
Events – der Tracker muss aber merken, dass die Geschwindigkeit auf 0 fällt.
|
||||
"""
|
||||
if self.drag.body is not None:
|
||||
self.drag.move(self.world_pointer, time)
|
||||
self.hovered_body = None if self.drag.body is not None else self._body_under_pointer()
|
||||
grabbing = self.drag.body is not None or self.hovered_body is not None
|
||||
cursor = pygame.SYSTEM_CURSOR_HAND if grabbing else pygame.SYSTEM_CURSOR_ARROW
|
||||
if cursor != self._cursor:
|
||||
self._cursor = cursor
|
||||
# Im Headless-Modus (Tests) gibt es keinen echten Mauszeiger.
|
||||
with contextlib.suppress(pygame.error):
|
||||
pygame.mouse.set_cursor(cursor)
|
||||
|
||||
def _offer_to_sidebar(self, event: pygame.event.Event) -> bool:
|
||||
"""Bietet ein Event der Sidebar an; True, wenn sie es verbraucht hat."""
|
||||
if self.sidebar is None:
|
||||
return False
|
||||
return self.sidebar.handle_event(event, self.mouse_pos)
|
||||
|
||||
def _press_in_world(self, button: int, time: float) -> None:
|
||||
if not self.pointer_in_world:
|
||||
return
|
||||
pointer = self.world_pointer
|
||||
if button == pygame.BUTTON_LEFT:
|
||||
body = self._body_under_pointer() or self.factory.create(self.tools, pointer)
|
||||
if body is not None:
|
||||
self.drag.grab(body, pointer, time)
|
||||
elif button == pygame.BUTTON_RIGHT:
|
||||
body = self._body_under_pointer()
|
||||
if body is not None:
|
||||
self.delete(body)
|
||||
|
||||
def _move(self, time: float) -> None:
|
||||
if self.spawn_drag_active and self.pointer_in_world:
|
||||
# Aus der Sidebar gezogenes Objekt erscheint, sobald die Maus die Welt erreicht,
|
||||
# und hängt ab dann wie ein gegriffenes Objekt an der Maus.
|
||||
self.spawn_drag_active = False
|
||||
body = self.factory.create(self.tools, self.world_pointer)
|
||||
if body is not None:
|
||||
self.drag.grab(body, self.world_pointer, time)
|
||||
if self.drag.body is not None:
|
||||
self.drag.move(self.world_pointer, time)
|
||||
|
||||
def _change_size(self, wheel_steps: int) -> None:
|
||||
config = self.config
|
||||
size = self.tools.size + wheel_steps * config.size_wheel_step
|
||||
self.tools.size = min(max(size, config.min_size), config.max_size)
|
||||
|
||||
def _body_under_pointer(self) -> Body | None:
|
||||
if not self.pointer_in_world:
|
||||
return None
|
||||
return self.world.body_at(self.world_pointer, self.config.pick_tolerance)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Werkzeug-Logik der Sandbox: Körper erzeugen, greifen, ziehen und werfen.
|
||||
|
||||
Die Klassen hier arbeiten ausschließlich mit Weltkoordinaten und Zeitstempeln, nicht mit
|
||||
pygame-Events. Dadurch lässt sich das Verhalten (z. B. die Glättung der
|
||||
Wurfgeschwindigkeit) ohne Fenster testen; die Übersetzung von Maus-Events übernimmt
|
||||
:mod:`sandbox.input_handler`.
|
||||
"""
|
||||
|
||||
from sandbox.interaction.drag import DragController
|
||||
from sandbox.interaction.tools import BodyFactory, BodyStyle, ShapeKind, ToolSettings
|
||||
from sandbox.interaction.velocity_tracker import VelocityTracker
|
||||
|
||||
__all__ = [
|
||||
"BodyFactory",
|
||||
"BodyStyle",
|
||||
"DragController",
|
||||
"ShapeKind",
|
||||
"ToolSettings",
|
||||
"VelocityTracker",
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Greifen, Ziehen und Werfen eines Körpers mit der Maus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sandbox.config import InteractionConfig
|
||||
from sandbox.interaction.velocity_tracker import VelocityTracker
|
||||
from sandbox.physics import ZERO, Body, Vector2, World
|
||||
|
||||
|
||||
class DragController:
|
||||
"""Führt einen gegriffenen Körper zur Maus, ohne die Physik auszuhebeln.
|
||||
|
||||
Der Körper wird *nicht* einfach an die Mausposition gesetzt – dann könnte er durch
|
||||
andere Körper und Wände hindurchgeschoben werden. Stattdessen bekommt er vor jedem
|
||||
Physikschritt die Geschwindigkeit, die ihn in Richtung Ziel bringt::
|
||||
|
||||
v = (Ziel − Position) · rate
|
||||
|
||||
Danach löst der Solver ganz normal die Kontakte: Leichte Körper werden weggeschoben,
|
||||
Wände und schwere Stapel bremsen den gezogenen Körper. Das gezogene Objekt kollidiert
|
||||
also weiterhin. Beim Loslassen erhält es die geglättete Mausgeschwindigkeit
|
||||
(:class:`~sandbox.interaction.velocity_tracker.VelocityTracker`) – das ist der Wurf.
|
||||
|
||||
Args:
|
||||
world: Die Welt, in der gezogen wird.
|
||||
config: Folgegeschwindigkeit, Wurf-Glättung und -Obergrenze.
|
||||
"""
|
||||
|
||||
def __init__(self, world: World, config: InteractionConfig) -> None:
|
||||
self.world = world
|
||||
self.config = config
|
||||
self.tracker = VelocityTracker(config.throw_window, config.throw_smoothing_time)
|
||||
self._body: Body | None = None
|
||||
self._offset = ZERO
|
||||
self._target = ZERO
|
||||
|
||||
@property
|
||||
def body(self) -> Body | None:
|
||||
"""Der aktuell gehaltene Körper (oder ``None``)."""
|
||||
return self._body
|
||||
|
||||
@property
|
||||
def target(self) -> Vector2:
|
||||
"""Zielposition des Schwerpunkts (Mausposition plus Griffversatz)."""
|
||||
return self._target
|
||||
|
||||
@property
|
||||
def throw_velocity(self) -> Vector2:
|
||||
"""Geschwindigkeit, die der Körper beim Loslassen erhielte (px/s)."""
|
||||
return self.tracker.velocity.clamped(self.config.max_throw_speed)
|
||||
|
||||
def grab(self, body: Body, pointer: Vector2, time: float) -> None:
|
||||
"""Greift einen Körper an der Stelle ``pointer``.
|
||||
|
||||
Der Versatz zwischen Griffpunkt und Schwerpunkt bleibt beim Ziehen erhalten, damit
|
||||
der Körper nicht mit seinem Mittelpunkt zur Maus springt.
|
||||
"""
|
||||
self.cancel()
|
||||
self._body = body
|
||||
body.held = True
|
||||
body.wake()
|
||||
self._offset = body.position - pointer
|
||||
self._target = body.position
|
||||
self.tracker.reset(time, pointer)
|
||||
|
||||
def move(self, pointer: Vector2, time: float) -> None:
|
||||
"""Meldet die aktuelle Mausposition (bei jedem Event und einmal pro Frame)."""
|
||||
body = self._body
|
||||
if body is None:
|
||||
return
|
||||
self.tracker.add_sample(time, pointer)
|
||||
# Das Ziel so klemmen, dass der Körper ganz in die Welt passt; sonst würde er
|
||||
# dauerhaft gegen die Wand gedrückt.
|
||||
self._target = self.world.clamp_inside(pointer + self._offset, body.half_extents())
|
||||
|
||||
def apply(self, dt: float) -> None:
|
||||
"""Setzt vor einem Physikschritt die Geschwindigkeit Richtung Ziel."""
|
||||
body = self._body
|
||||
if body is None:
|
||||
return
|
||||
# rate·dt ≤ 1, sonst würde der Körper über das Ziel hinausschießen und pendeln.
|
||||
rate = min(self.config.drag_response_rate, 1.0 / dt)
|
||||
velocity = (self._target - body.position) * rate
|
||||
body.velocity = velocity.clamped(self.world.config.max_speed)
|
||||
body.wake()
|
||||
|
||||
def place(self) -> None:
|
||||
"""Setzt den Körper direkt aufs Ziel – nur im Pausenmodus, wenn keine Physik läuft."""
|
||||
body = self._body
|
||||
if body is None:
|
||||
return
|
||||
body.teleport(self._target)
|
||||
body.velocity = ZERO
|
||||
|
||||
def release(self, *, paused: bool) -> Body | None:
|
||||
"""Lässt den Körper los und wirft ihn mit der geglätteten Mausgeschwindigkeit.
|
||||
|
||||
Args:
|
||||
paused: Im Pausenmodus wird nicht geworfen; der Körper bleibt liegen.
|
||||
|
||||
Returns:
|
||||
Den losgelassenen Körper (oder ``None``, wenn keiner gehalten wurde).
|
||||
"""
|
||||
body = self._body
|
||||
if body is None:
|
||||
return None
|
||||
body.velocity = ZERO if paused else self.throw_velocity
|
||||
self.cancel()
|
||||
return body
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Beendet das Halten, ohne die Geschwindigkeit zu ändern (z. B. beim Löschen)."""
|
||||
if self._body is not None:
|
||||
self._body.held = False
|
||||
self._body.wake()
|
||||
self._body = None
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Werkzeug-Einstellungen der Sidebar und das Erzeugen neuer Körper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from sandbox.config import MIN_BODY_SIZE, Color, InteractionConfig
|
||||
from sandbox.physics import Body, Material, Vector2, World
|
||||
|
||||
|
||||
class ShapeKind(Enum):
|
||||
"""Form neu erzeugter Körper."""
|
||||
|
||||
CIRCLE = "circle"
|
||||
BOX = "box"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSettings:
|
||||
"""Aktuelle Auswahl in der Werkzeugleiste.
|
||||
|
||||
Attributes:
|
||||
shape: Form neuer Körper.
|
||||
size: Radius bzw. "mittlere" halbe Kantenlänge in px.
|
||||
aspect_ratio: Seitenverhältnis Breite:Höhe (nur für Rechtecke).
|
||||
density: Dichte (Masse = Fläche × Dichte).
|
||||
restitution: Elastizität (Stoßzahl).
|
||||
color_index: Index in die Farbpalette oder ``None`` für eine Zufallsfarbe.
|
||||
"""
|
||||
|
||||
shape: ShapeKind
|
||||
size: float
|
||||
aspect_ratio: float
|
||||
density: float
|
||||
restitution: float
|
||||
color_index: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: InteractionConfig) -> ToolSettings:
|
||||
"""Startwerte aus der Konfiguration."""
|
||||
return cls(
|
||||
shape=ShapeKind.CIRCLE,
|
||||
size=config.default_size,
|
||||
aspect_ratio=config.default_aspect_ratio,
|
||||
density=config.default_density,
|
||||
restitution=config.default_restitution,
|
||||
)
|
||||
|
||||
def half_extents(self) -> tuple[float, float]:
|
||||
"""Halbe Breite und Höhe eines neuen Körpers.
|
||||
|
||||
Für Rechtecke wird die Größe mit √(Seitenverhältnis) gestreckt bzw. gestaucht. So
|
||||
bleibt die Fläche – und damit die Masse – beim Ändern des Seitenverhältnisses gleich,
|
||||
nur die Form ändert sich. Ausnahme: Keine halbe Kantenlänge darf unter
|
||||
MIN_BODY_SIZE fallen, weil die Geschwindigkeitsgrenze gegen Tunneln auf dieser
|
||||
Mindestgröße beruht. Bei sehr kleinen Rechtecken wird die kurze Seite deshalb
|
||||
angehoben, und die Fläche wächst etwas.
|
||||
"""
|
||||
if self.shape is ShapeKind.CIRCLE:
|
||||
return (self.size, self.size)
|
||||
stretch = math.sqrt(self.aspect_ratio)
|
||||
return (max(self.size * stretch, MIN_BODY_SIZE), max(self.size / stretch, MIN_BODY_SIZE))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BodyStyle:
|
||||
"""Darstellungsdaten eines Körpers (liegen in ``Body.user_data``).
|
||||
|
||||
Attributes:
|
||||
color: Füllfarbe.
|
||||
"""
|
||||
|
||||
color: Color
|
||||
|
||||
|
||||
class BodyFactory:
|
||||
"""Erzeugt Körper gemäß den Werkzeug-Einstellungen und fügt sie der Welt hinzu.
|
||||
|
||||
Args:
|
||||
world: Zielwelt.
|
||||
config: Grenzen und Materialwerte.
|
||||
palette: Verfügbare Farben.
|
||||
rng: Zufallsgenerator für Zufallsfarben (injizierbar für reproduzierbare Tests).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
world: World,
|
||||
config: InteractionConfig,
|
||||
palette: tuple[Color, ...],
|
||||
rng: random.Random | None = None,
|
||||
) -> None:
|
||||
self.world = world
|
||||
self.config = config
|
||||
self.palette = palette
|
||||
self._rng = rng or random.Random()
|
||||
# Die nächste Zufallsfarbe wird im Voraus gewürfelt, damit die Vorschau-Kachel
|
||||
# genau die Farbe zeigt, die das nächste Objekt bekommt.
|
||||
self._next_random_color = self._rng.choice(palette)
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
"""True, wenn die Höchstzahl an Körpern erreicht ist."""
|
||||
return len(self.world.bodies) >= self.config.max_bodies
|
||||
|
||||
def color_for(self, settings: ToolSettings) -> Color:
|
||||
"""Farbe, die das nächste Objekt mit diesen Einstellungen bekommt."""
|
||||
if settings.color_index is None:
|
||||
return self._next_random_color
|
||||
return self.palette[settings.color_index]
|
||||
|
||||
def create(self, settings: ToolSettings, position: Vector2) -> Body | None:
|
||||
"""Erzeugt einen Körper an ``position`` (wird bei Bedarf in die Welt geschoben).
|
||||
|
||||
Returns:
|
||||
Den neuen Körper oder ``None``, wenn die Höchstzahl erreicht ist.
|
||||
"""
|
||||
if self.is_full:
|
||||
return None
|
||||
half_width, half_height = settings.half_extents()
|
||||
center = self.world.clamp_inside(position, (half_width, half_height))
|
||||
material = Material(
|
||||
density=settings.density,
|
||||
restitution=settings.restitution,
|
||||
friction=self.config.friction,
|
||||
)
|
||||
style = BodyStyle(self.color_for(settings))
|
||||
if settings.color_index is None:
|
||||
self._next_random_color = self._rng.choice(self.palette)
|
||||
if settings.shape is ShapeKind.CIRCLE:
|
||||
body = Body.circle(center, half_width, material, user_data=style)
|
||||
else:
|
||||
body = Body.box(center, half_width, half_height, material, user_data=style)
|
||||
return self.world.add_body(body)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Geglättete Zeigergeschwindigkeit für das Werfen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import deque
|
||||
|
||||
from sandbox.physics import ZERO, Vector2
|
||||
|
||||
|
||||
class VelocityTracker:
|
||||
"""Schätzt, wie schnell sich die Maus zuletzt bewegt hat.
|
||||
|
||||
Die Geschwindigkeit zwischen nur zwei aufeinanderfolgenden Frames ist sehr unruhig:
|
||||
Maus-Events kommen unregelmäßig, und ein einzelner kurzer Frame liefert einen riesigen
|
||||
Wert. Deshalb zwei Stufen:
|
||||
|
||||
1. **Zeitfenster:** Mittlere Geschwindigkeit über die letzten ``window`` Sekunden
|
||||
(Weg / Zeit zwischen ältester und neuester Probe) – das entspricht "der Bewegung der
|
||||
letzten Frames" und ist unabhängig von der Bildrate.
|
||||
2. **Exponentielle Glättung:** ``v ← v + (v_roh − v)·(1 − e^(−Δt/τ))``. Der
|
||||
Exponentialterm macht die Glättung ebenfalls bildratenunabhängig: Zwei Frames à
|
||||
8 ms glätten genauso stark wie ein Frame à 16 ms.
|
||||
|
||||
Hält man die Maus vor dem Loslassen still, sinkt die Geschwindigkeit schnell gegen 0 –
|
||||
das Objekt fällt dann einfach herunter, statt mit einem veralteten Schwung davonzufliegen.
|
||||
|
||||
Args:
|
||||
window: Länge des Mittelungsfensters in s.
|
||||
smoothing_time: Zeitkonstante τ der exponentiellen Glättung in s.
|
||||
"""
|
||||
|
||||
def __init__(self, window: float, smoothing_time: float) -> None:
|
||||
if not (window > 0.0 and smoothing_time > 0.0):
|
||||
raise ValueError("Fenster und Glättungszeit müssen positiv sein")
|
||||
self.window = window
|
||||
self.smoothing_time = smoothing_time
|
||||
self._samples: deque[tuple[float, Vector2]] = deque()
|
||||
self._velocity = ZERO
|
||||
|
||||
@property
|
||||
def velocity(self) -> Vector2:
|
||||
"""Aktuelle geglättete Geschwindigkeit in px/s."""
|
||||
return self._velocity
|
||||
|
||||
def reset(self, time: float, position: Vector2) -> None:
|
||||
"""Beginnt eine neue Messung (z. B. beim Greifen eines Körpers)."""
|
||||
self._samples.clear()
|
||||
self._samples.append((time, position))
|
||||
self._velocity = ZERO
|
||||
|
||||
def add_sample(self, time: float, position: Vector2) -> None:
|
||||
"""Nimmt eine neue Zeigerposition auf.
|
||||
|
||||
Args:
|
||||
time: Zeitstempel in s (monoton steigend).
|
||||
position: Zeigerposition in Weltkoordinaten.
|
||||
"""
|
||||
samples = self._samples
|
||||
if not samples:
|
||||
self.reset(time, position)
|
||||
return
|
||||
last_time = samples[-1][0]
|
||||
elapsed = time - last_time
|
||||
if elapsed <= 0.0:
|
||||
# Mehrere Events im selben Frame: nur die neueste Position zählt.
|
||||
samples[-1] = (last_time, position)
|
||||
return
|
||||
samples.append((time, position))
|
||||
# Proben vor dem Fenster verwerfen – die jüngste davon bleibt als Bezugspunkt,
|
||||
# damit das Fenster auch bei langen Frames nie leer ist. (Die neueste Probe liegt
|
||||
# immer im Fenster, es bleiben also stets mindestens zwei Proben übrig.)
|
||||
while len(samples) > 1 and samples[1][0] <= time - self.window:
|
||||
samples.popleft()
|
||||
|
||||
oldest_time, oldest_position = samples[0]
|
||||
raw = (position - oldest_position) / (time - oldest_time)
|
||||
blend = 1.0 - math.exp(-elapsed / self.smoothing_time)
|
||||
self._velocity = self._velocity + (raw - self._velocity) * blend
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Einstiegspunkt und Hauptschleife der Physik-Sandbox.
|
||||
|
||||
Ablauf eines Frames (:meth:`App.frame`):
|
||||
|
||||
1. Events verarbeiten (Sidebar, Werkzeuge, Tastatur).
|
||||
2. Die vergangene Zeit an den festen Zeitschritt geben; er liefert die Zahl fälliger
|
||||
Physikschritte (bei 60 Bildern/s meist zwei Schritte à 1/120 s).
|
||||
3. Vor jedem Physikschritt die Geschwindigkeit eines gezogenen Körpers setzen, dann
|
||||
``World.step`` ausführen.
|
||||
4. Zeichnen – mit Interpolation zwischen den letzten beiden Physikzuständen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import APP, MILLISECONDS_PER_SECOND, AppConfig, Color
|
||||
from sandbox.demo import build_demo_scene
|
||||
from sandbox.input_handler import InputHandler
|
||||
from sandbox.interaction import BodyFactory, DragController, ToolSettings
|
||||
from sandbox.physics import FixedTimestep, World
|
||||
from sandbox.renderer import Renderer, WorldOverlay
|
||||
from sandbox.ui.sidebar import Sidebar
|
||||
from sandbox.ui.style import UiStyle
|
||||
from sandbox.viewport import Viewport
|
||||
|
||||
|
||||
class FrameStats:
|
||||
"""Geglättete Bildrate und Physik-Rechenzeit für die Anzeige.
|
||||
|
||||
Args:
|
||||
smoothing: Anteil (0..1), mit dem ein neuer Messwert pro Frame einfließt.
|
||||
"""
|
||||
|
||||
def __init__(self, smoothing: float) -> None:
|
||||
self.smoothing = smoothing
|
||||
self.fps = 0.0
|
||||
self.physics_ms = 0.0
|
||||
|
||||
def update(self, frame_time: float, physics_seconds: float) -> None:
|
||||
"""Nimmt die Messwerte eines Frames auf."""
|
||||
if frame_time > 0.0:
|
||||
fps = 1.0 / frame_time
|
||||
self.fps = fps if self.fps == 0.0 else self.fps + (fps - self.fps) * self.smoothing
|
||||
physics_ms = physics_seconds * MILLISECONDS_PER_SECOND
|
||||
self.physics_ms += (physics_ms - self.physics_ms) * self.smoothing
|
||||
|
||||
|
||||
class App:
|
||||
"""Die Anwendung: Fenster, Welt, Werkzeuge, Sidebar und Hauptschleife.
|
||||
|
||||
Args:
|
||||
config: Gesamtkonfiguration.
|
||||
rng: Zufallsgenerator für Zufallsfarben (für reproduzierbare Tests).
|
||||
"""
|
||||
|
||||
def __init__(self, config: AppConfig = APP, *, rng: random.Random | None = None) -> None:
|
||||
pygame.init()
|
||||
self.config = config
|
||||
window = config.window
|
||||
self.screen = pygame.display.set_mode((window.width, window.height))
|
||||
pygame.display.set_caption(window.title)
|
||||
self.clock = pygame.time.Clock()
|
||||
|
||||
self.viewport = Viewport(
|
||||
window.sidebar_width, 0, window.width - window.sidebar_width, window.height
|
||||
)
|
||||
self.world = World(self.viewport.width, self.viewport.height, config.physics)
|
||||
self.timestep = FixedTimestep(config.physics.time_step, window.max_steps_per_frame)
|
||||
self.tools = ToolSettings.from_config(config.interaction)
|
||||
self.factory = BodyFactory(
|
||||
self.world, config.interaction, config.theme.body_palette, rng or random.Random()
|
||||
)
|
||||
self.drag = DragController(self.world, config.interaction)
|
||||
self.style = UiStyle(config.theme, config.ui)
|
||||
self.input = InputHandler(
|
||||
world=self.world,
|
||||
viewport=self.viewport,
|
||||
drag=self.drag,
|
||||
factory=self.factory,
|
||||
tools=self.tools,
|
||||
config=config.interaction,
|
||||
controls=self,
|
||||
)
|
||||
self.sidebar = Sidebar(
|
||||
pygame.Rect(0, 0, window.sidebar_width, window.height),
|
||||
self.style,
|
||||
self.tools,
|
||||
config.interaction,
|
||||
config.theme.body_palette,
|
||||
self,
|
||||
)
|
||||
self.input.sidebar = self.sidebar
|
||||
self.renderer = Renderer(self.screen, self.viewport, self.style)
|
||||
self.stats = FrameStats(window.stats_smoothing)
|
||||
|
||||
self.paused = False
|
||||
self.debug = False
|
||||
self.running = True
|
||||
self.time = 0.0
|
||||
|
||||
# ------------------------------------------------------------------ Steuerung
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
"""Ob die Simulation pausiert ist."""
|
||||
return self.paused
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
"""Pausiert bzw. setzt die Simulation fort."""
|
||||
self.paused = not self.paused
|
||||
# Angesammelte Zeit verwerfen, sonst holte die Physik nach der Pause Schritte nach.
|
||||
self.timestep.reset()
|
||||
|
||||
def gravity_enabled(self) -> bool:
|
||||
"""Ob die Schwerkraft wirkt."""
|
||||
return self.world.gravity_enabled
|
||||
|
||||
def toggle_gravity(self) -> None:
|
||||
"""Schaltet die Schwerkraft an bzw. aus."""
|
||||
self.world.gravity_enabled = not self.world.gravity_enabled
|
||||
|
||||
def clear_world(self) -> None:
|
||||
"""Entfernt alle Körper."""
|
||||
self.input.forget_bodies()
|
||||
self.world.clear()
|
||||
|
||||
def begin_spawn_drag(self) -> None:
|
||||
"""Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel."""
|
||||
self.input.begin_spawn_drag()
|
||||
|
||||
def preview_color(self) -> Color:
|
||||
"""Farbe des nächsten Objekts."""
|
||||
return self.factory.color_for(self.tools)
|
||||
|
||||
def status_lines(self) -> list[str]:
|
||||
"""Statuszeilen am unteren Rand der Sidebar."""
|
||||
limit = self.config.interaction.max_bodies
|
||||
count = len(self.world.bodies)
|
||||
full = " (voll)" if count >= limit else ""
|
||||
return [
|
||||
f"Objekte: {count} / {limit}{full}",
|
||||
f"{self.stats.fps:.0f} FPS · Physik {self.stats.physics_ms:.1f} ms",
|
||||
"Rechtsklick: löschen · Rad: Größe",
|
||||
"F1: Debug · Esc: Beenden",
|
||||
]
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Beendet die Hauptschleife nach dem aktuellen Frame."""
|
||||
self.running = False
|
||||
|
||||
def toggle_debug(self) -> None:
|
||||
"""Schaltet die Debug-Ansicht (Kontakte, Schlafzustand) um."""
|
||||
self.debug = not self.debug
|
||||
|
||||
def load_demo_scene(self) -> None:
|
||||
"""Ersetzt den Inhalt der Welt durch die Beispielszene."""
|
||||
self.clear_world()
|
||||
build_demo_scene(self.factory, self.tools, self.config.demo)
|
||||
|
||||
# ------------------------------------------------------------------ Hauptschleife
|
||||
|
||||
def frame(self, frame_time: float) -> None:
|
||||
"""Verarbeitet Eingaben, rechnet die Physik und zeichnet ein Bild.
|
||||
|
||||
Args:
|
||||
frame_time: Real vergangene Zeit seit dem letzten Frame in s.
|
||||
"""
|
||||
self.time += frame_time
|
||||
for event in pygame.event.get():
|
||||
self.input.handle_event(event, self.time)
|
||||
self.input.update(self.time)
|
||||
|
||||
physics_start = time.perf_counter()
|
||||
if self.paused:
|
||||
self.drag.place() # Im Pausenmodus wird direkt verschoben, ohne Physik.
|
||||
else:
|
||||
dt = self.timestep.step
|
||||
for _ in range(self.timestep.advance(frame_time)):
|
||||
self.drag.apply(dt)
|
||||
self.world.step(dt)
|
||||
self.stats.update(frame_time, time.perf_counter() - physics_start)
|
||||
|
||||
self.draw()
|
||||
pygame.display.flip()
|
||||
|
||||
def draw(self) -> None:
|
||||
"""Zeichnet Welt, Sidebar und – obenauf – die Vorschau eines neuen Objekts."""
|
||||
overlay = WorldOverlay(
|
||||
hovered=self.input.hovered_body,
|
||||
held=self.drag.body,
|
||||
throw_velocity=self.drag.throw_velocity,
|
||||
paused=self.paused,
|
||||
debug=self.debug,
|
||||
debug_lines=self._debug_lines() if self.debug else [],
|
||||
)
|
||||
alpha = 1.0 if self.paused else self.timestep.alpha
|
||||
self.renderer.draw_world(self.world, alpha, overlay)
|
||||
self.sidebar.draw(self.screen)
|
||||
preview = self.input.spawn_preview()
|
||||
if preview is not None:
|
||||
self.renderer.draw_preview(preview)
|
||||
|
||||
def _debug_lines(self) -> list[str]:
|
||||
awake = sum(not body.sleeping for body in self.world.bodies)
|
||||
return [
|
||||
f"Körper: {len(self.world.bodies)} (wach: {awake})",
|
||||
f"Kontakte: {len(self.world.contacts)}",
|
||||
f"Physik: {self.stats.physics_ms:.2f} ms/Frame · Schritt {self.world.step_count}",
|
||||
]
|
||||
|
||||
def run(self, max_seconds: float | None = None) -> None:
|
||||
"""Führt die Hauptschleife aus, bis das Fenster geschlossen wird.
|
||||
|
||||
Args:
|
||||
max_seconds: Optional; beendet die Schleife nach dieser Laufzeit (für
|
||||
Screenshots und den Smoke-Test).
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
self.clock.tick() # Zeitmessung ab jetzt, nicht ab dem Erzeugen der App
|
||||
while self.running:
|
||||
frame_time = self.clock.tick(self.config.window.fps_limit) / MILLISECONDS_PER_SECOND
|
||||
self.frame(frame_time)
|
||||
if max_seconds is not None and time.perf_counter() - start >= max_seconds:
|
||||
break
|
||||
|
||||
def save_screenshot(self, path: Path) -> None:
|
||||
"""Speichert den aktuellen Fensterinhalt als Bild."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pygame.image.save(self.screen, str(path))
|
||||
|
||||
def close(self) -> None:
|
||||
"""Gibt Fenster und pygame-Ressourcen frei."""
|
||||
pygame.quit()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Startet die Sandbox (Kommandozeilen-Einstiegspunkt).
|
||||
|
||||
Args:
|
||||
argv: Kommandozeilenargumente (Standard: ``sys.argv[1:]``).
|
||||
|
||||
Returns:
|
||||
Exit-Code 0.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="physics-sandbox", description="Interaktive 2D-Physik-Sandbox"
|
||||
)
|
||||
parser.add_argument("--demo", action="store_true", help="mit einer Beispielszene starten")
|
||||
parser.add_argument(
|
||||
"--screenshot",
|
||||
type=Path,
|
||||
metavar="DATEI",
|
||||
help="nach --seconds Sekunden ein Bildschirmfoto speichern und beenden",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seconds",
|
||||
type=float,
|
||||
default=APP.window.screenshot_delay,
|
||||
help="Laufzeit vor dem Bildschirmfoto (Standard: %(default)s)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
app = App()
|
||||
try:
|
||||
if args.demo:
|
||||
app.load_demo_scene()
|
||||
if args.screenshot is not None:
|
||||
app.run(max_seconds=args.seconds)
|
||||
app.save_screenshot(args.screenshot)
|
||||
else:
|
||||
app.run()
|
||||
finally:
|
||||
app.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Pygame-freie 2D-Physik-Engine.
|
||||
|
||||
Die Physik verwendet ausschließlich das Standardmodul ``math`` (plus ``typing`` und
|
||||
``dataclasses`` für Typangaben und Datenstrukturen). Ein Architektur-Test stellt sicher,
|
||||
dass hier nie pygame oder eine andere Bibliothek importiert wird.
|
||||
|
||||
Wichtigste Bausteine:
|
||||
|
||||
* :class:`Vector2` – eigene Vektorklasse.
|
||||
* :class:`Circle`, :class:`Box`, :class:`HalfPlane` – Formen.
|
||||
* :class:`Body`, :class:`Material` – starre Körper.
|
||||
* :class:`World` – verwaltet Körper und führt Simulationsschritte aus.
|
||||
* :class:`FixedTimestep` – übersetzt Frame-Zeiten in feste Physikschritte.
|
||||
"""
|
||||
|
||||
from sandbox.physics.body import Body, Material
|
||||
from sandbox.physics.collision import Contact, collide
|
||||
from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape
|
||||
from sandbox.physics.timestep import FixedTimestep
|
||||
from sandbox.physics.vector import ZERO, Vector2
|
||||
from sandbox.physics.world import World
|
||||
|
||||
__all__ = [
|
||||
"ZERO",
|
||||
"Body",
|
||||
"Box",
|
||||
"Circle",
|
||||
"Contact",
|
||||
"FixedTimestep",
|
||||
"HalfPlane",
|
||||
"Material",
|
||||
"Shape",
|
||||
"Vector2",
|
||||
"World",
|
||||
"collide",
|
||||
]
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Starre Körper und ihre Materialeigenschaften."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape
|
||||
from sandbox.physics.vector import ZERO, Vector2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Material:
|
||||
"""Stoffeigenschaften eines Körpers.
|
||||
|
||||
Attributes:
|
||||
density: Masse pro Fläche. Die Masse eines Körpers ist Fläche × Dichte, größere
|
||||
Körper sind bei gleicher Dichte also schwerer.
|
||||
restitution: Stoßzahl e ∈ [0, 1]. Verhältnis von Trenn- zu Aufprallgeschwindigkeit:
|
||||
0 = völlig unelastisch (kein Abprallen), 1 = vollkommen elastisch.
|
||||
friction: Reibungskoeffizient μ ≥ 0 (Coulomb: Reibkraft ≤ μ · Normalkraft).
|
||||
"""
|
||||
|
||||
density: float
|
||||
restitution: float
|
||||
friction: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.density < 0.0:
|
||||
raise ValueError(f"Dichte darf nicht negativ sein: {self.density}")
|
||||
if not 0.0 <= self.restitution <= 1.0:
|
||||
raise ValueError(f"Restitution muss in [0, 1] liegen: {self.restitution}")
|
||||
if self.friction < 0.0:
|
||||
raise ValueError(f"Reibung darf nicht negativ sein: {self.friction}")
|
||||
|
||||
|
||||
class Body:
|
||||
"""Ein starrer Körper der Simulation.
|
||||
|
||||
Dynamische Körper haben eine endliche Masse (Fläche × Dichte) und werden von Schwerkraft
|
||||
und Kontakten bewegt. Statische Körper (die Wände) haben unendliche Masse; gespeichert
|
||||
wird dafür die *inverse* Masse 0. Mit inversen Massen lassen sich beide Fälle ohne
|
||||
Sonderbehandlung in dieselben Formeln einsetzen: Ein Impuls ändert die Geschwindigkeit
|
||||
um ``Impuls · inv_mass`` – bei statischen Körpern also gar nicht.
|
||||
|
||||
Attributes:
|
||||
id: Eindeutige, fortlaufende Kennung (für Kontakt-Caches und deterministische
|
||||
Reihenfolgen).
|
||||
shape: Geometrie des Körpers.
|
||||
material: Dichte, Restitution und Reibung.
|
||||
position: Position des Schwerpunkts in px.
|
||||
velocity: Geschwindigkeit des Schwerpunkts in px/s.
|
||||
angle: Drehwinkel in rad (nur Kreise drehen sich).
|
||||
angular_velocity: Winkelgeschwindigkeit in rad/s.
|
||||
is_static: True für unbewegliche Körper (Wände).
|
||||
mass: Masse (``math.inf`` bei statischen Körpern).
|
||||
inv_mass: 1 / Masse (0 bei statischen Körpern).
|
||||
inertia: Trägheitsmoment (``math.inf``, wenn sich der Körper nicht drehen kann).
|
||||
inv_inertia: 1 / Trägheitsmoment (0, wenn sich der Körper nicht drehen kann).
|
||||
sleeping: True, solange der Körper im Schlafmodus eingefroren ist.
|
||||
sleep_time: Wie lange (s) der Körper schon ununterbrochen fast stillsteht.
|
||||
sleep_island: Die Körper, mit denen dieser Körper gemeinsam eingeschlafen ist
|
||||
(einschließlich ihm selbst), oder ``None``. Sie werden nur gemeinsam geweckt,
|
||||
siehe :meth:`wake`.
|
||||
held: True, solange der Körper mit der Maus gezogen wird. Gehaltene Körper
|
||||
unterliegen keiner Schwerkraft und schlafen nie ein.
|
||||
previous_position: Position zu Beginn des letzten Schritts (für die
|
||||
Zeichen-Interpolation zwischen zwei Physikschritten).
|
||||
previous_angle: Winkel zu Beginn des letzten Schritts.
|
||||
previous_velocity: Geschwindigkeit zu Beginn des letzten Schritts, also vor
|
||||
Schwerkraft und Kontakten. Daraus bestimmt der Solver die Aufprallgeschwindigkeit
|
||||
für das Abprallen.
|
||||
user_data: Beliebige Zusatzdaten der Anwendung (z. B. die Farbe). Die Physik
|
||||
liest dieses Feld nie.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"angle",
|
||||
"angular_velocity",
|
||||
"held",
|
||||
"id",
|
||||
"inertia",
|
||||
"inv_inertia",
|
||||
"inv_mass",
|
||||
"is_static",
|
||||
"mass",
|
||||
"material",
|
||||
"position",
|
||||
"previous_angle",
|
||||
"previous_position",
|
||||
"previous_velocity",
|
||||
"shape",
|
||||
"sleep_island",
|
||||
"sleep_time",
|
||||
"sleeping",
|
||||
"user_data",
|
||||
"velocity",
|
||||
)
|
||||
|
||||
_next_id: ClassVar[int] = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shape: Shape,
|
||||
position: Vector2,
|
||||
material: Material,
|
||||
*,
|
||||
is_static: bool = False,
|
||||
velocity: Vector2 = ZERO,
|
||||
angular_velocity: float = 0.0,
|
||||
user_data: object = None,
|
||||
) -> None:
|
||||
self.id = Body._next_id
|
||||
Body._next_id += 1
|
||||
|
||||
self.shape = shape
|
||||
self.material = material
|
||||
self.position = position
|
||||
self.previous_position = position
|
||||
self.angle = 0.0
|
||||
self.previous_angle = 0.0
|
||||
self.is_static = is_static
|
||||
self.sleeping = False
|
||||
self.sleep_time = 0.0
|
||||
self.sleep_island: list[Body] | None = None
|
||||
self.held = False
|
||||
self.user_data = user_data
|
||||
|
||||
if is_static:
|
||||
self.mass = math.inf
|
||||
self.inv_mass = 0.0
|
||||
self.inertia = math.inf
|
||||
self.inv_inertia = 0.0
|
||||
else:
|
||||
if isinstance(shape, HalfPlane):
|
||||
raise ValueError("Halbebenen können nur statische Körper sein")
|
||||
mass = shape.area() * material.density
|
||||
if not mass > 0.0:
|
||||
raise ValueError("Dynamische Körper brauchen eine Dichte > 0")
|
||||
self.mass = mass
|
||||
self.inv_mass = 1.0 / mass
|
||||
self.inertia = shape.moment_of_inertia(mass)
|
||||
self.inv_inertia = 0.0 if math.isinf(self.inertia) else 1.0 / self.inertia
|
||||
|
||||
# Statische Körper bewegen sich nie; Körper ohne Drehfreiheit drehen sich nie.
|
||||
self.velocity = ZERO if is_static else velocity
|
||||
self.previous_velocity = self.velocity
|
||||
self.angular_velocity = angular_velocity if self.inv_inertia > 0.0 else 0.0
|
||||
|
||||
# ------------------------------------------------------------------ Fabrikmethoden
|
||||
|
||||
@classmethod
|
||||
def circle(
|
||||
cls,
|
||||
position: Vector2,
|
||||
radius: float,
|
||||
material: Material,
|
||||
*,
|
||||
velocity: Vector2 = ZERO,
|
||||
user_data: object = None,
|
||||
) -> Body:
|
||||
"""Erzeugt einen dynamischen Kreis."""
|
||||
return cls(Circle(radius), position, material, velocity=velocity, user_data=user_data)
|
||||
|
||||
@classmethod
|
||||
def box(
|
||||
cls,
|
||||
position: Vector2,
|
||||
half_width: float,
|
||||
half_height: float,
|
||||
material: Material,
|
||||
*,
|
||||
velocity: Vector2 = ZERO,
|
||||
user_data: object = None,
|
||||
) -> Body:
|
||||
"""Erzeugt ein dynamisches achsenparalleles Rechteck."""
|
||||
return cls(
|
||||
Box(half_width, half_height), position, material, velocity=velocity, user_data=user_data
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def wall(cls, normal: Vector2, offset: float, material: Material) -> Body:
|
||||
"""Erzeugt eine statische Wand (Halbebene ``normal·p ≥ offset`` ist frei)."""
|
||||
return cls(HalfPlane(normal, offset), ZERO, material, is_static=True)
|
||||
|
||||
# ------------------------------------------------------------------ Abfragen
|
||||
|
||||
@property
|
||||
def can_rotate(self) -> bool:
|
||||
"""True, wenn Drehimpulse den Körper drehen können (nur dynamische Kreise)."""
|
||||
return self.inv_inertia > 0.0
|
||||
|
||||
def half_extents(self) -> tuple[float, float]:
|
||||
"""Halbe Breite und Höhe der umschließenden achsenparallelen Box."""
|
||||
return self.shape.half_extents()
|
||||
|
||||
def bounds(self) -> tuple[float, float, float, float]:
|
||||
"""Umschließende Box als ``(min_x, min_y, max_x, max_y)``."""
|
||||
half_x, half_y = self.shape.half_extents()
|
||||
x = self.position.x
|
||||
y = self.position.y
|
||||
return (x - half_x, y - half_y, x + half_x, y + half_y)
|
||||
|
||||
def contains_point(self, point: Vector2, tolerance: float = 0.0) -> bool:
|
||||
"""Prüft, ob ein Weltpunkt im Körper liegt (``tolerance`` vergrößert den Körper)."""
|
||||
return self.shape.contains(point - self.position, tolerance)
|
||||
|
||||
def kinetic_energy(self) -> float:
|
||||
"""Bewegungsenergie ``½·m·v² + ½·I·ω²`` (0 für statische Körper)."""
|
||||
if self.is_static:
|
||||
return 0.0
|
||||
energy = 0.5 * self.mass * self.velocity.length_squared()
|
||||
if self.can_rotate:
|
||||
energy += 0.5 * self.inertia * self.angular_velocity * self.angular_velocity
|
||||
return energy
|
||||
|
||||
def momentum(self) -> Vector2:
|
||||
"""Impuls ``p = m·v``."""
|
||||
if self.is_static:
|
||||
return ZERO
|
||||
return self.velocity * self.mass
|
||||
|
||||
# ------------------------------------------------------------------ Zustandsänderungen
|
||||
|
||||
def apply_impulse(self, impulse: Vector2, offset: Vector2 = ZERO) -> None:
|
||||
"""Wendet einen Impuls an einem Punkt an.
|
||||
|
||||
Args:
|
||||
impulse: Impuls (Kraftstoß) in Masse·px/s.
|
||||
offset: Angriffspunkt relativ zum Schwerpunkt. Ein Impuls, der nicht durch den
|
||||
Schwerpunkt geht, erzeugt zusätzlich einen Drehimpuls ``offset × impulse``.
|
||||
"""
|
||||
if self.is_static:
|
||||
return
|
||||
self.velocity = self.velocity + impulse * self.inv_mass
|
||||
self.angular_velocity += self.inv_inertia * offset.cross(impulse)
|
||||
|
||||
def wake(self) -> None:
|
||||
"""Weckt den Körper – und alle Körper, mit denen er eingeschlafen ist.
|
||||
|
||||
Warum die ganze Insel? Zwei schlafende Körper werden nicht auf Kontakte
|
||||
untereinander geprüft. Würde nur die unterste Kiste eines schlafenden Stapels
|
||||
geweckt und im selben Schritt weggestoßen (oder in der Pause weggezogen), bemerkte
|
||||
niemand, dass die Kisten darüber ihre Stütze verloren haben – sie schwebten.
|
||||
"""
|
||||
island = self.sleep_island
|
||||
for body in island if island is not None else (self,):
|
||||
body.sleeping = False
|
||||
body.sleep_time = 0.0
|
||||
body.sleep_island = None
|
||||
|
||||
def put_to_sleep(self, island: list[Body] | None = None) -> None:
|
||||
"""Friert den Körper ein: keine Bewegung, bis ihn etwas aufweckt.
|
||||
|
||||
Args:
|
||||
island: Die Körper, die gemeinsam mit diesem einschlafen (einschließlich
|
||||
ihm selbst). Weckt später einer von ihnen auf, wachen alle auf.
|
||||
"""
|
||||
self.sleeping = True
|
||||
self.sleep_island = island
|
||||
self.velocity = ZERO
|
||||
self.previous_velocity = ZERO
|
||||
self.angular_velocity = 0.0
|
||||
# Ohne diese Angleichung würde die Zeichen-Interpolation einen längst ruhenden
|
||||
# Körper noch zwischen zwei alten Positionen hin- und herschieben.
|
||||
self.previous_position = self.position
|
||||
self.previous_angle = self.angle
|
||||
|
||||
def teleport(self, position: Vector2) -> None:
|
||||
"""Setzt die Position direkt (ohne Interpolations-Schlieren) und weckt den Körper."""
|
||||
self.position = position
|
||||
self.previous_position = position
|
||||
self.wake()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
state = " schläft" if self.sleeping else ""
|
||||
return f"Body(id={self.id}, {self.shape!r}, position={self.position!r}{state})"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Grobe Vorauswahl möglicher Kollisionspaare ("Broadphase") per Sweep-and-Prune.
|
||||
|
||||
Warum: Jedes Paar von n Körpern genau zu prüfen kostet n·(n−1)/2 Tests – bei 150 Körpern
|
||||
über 11 000 pro Schritt, 120-mal pro Sekunde. In reinem Python ist das zu langsam.
|
||||
|
||||
Sweep-and-Prune sortiert die umschließenden Boxen nach ihrer linken Kante und läuft einmal
|
||||
von links nach rechts. Für jede Box müssen nur die Nachfolger geprüft werden, deren linke
|
||||
Kante noch vor ihrer rechten Kante liegt; alle weiteren beginnen noch weiter rechts und
|
||||
können nicht überlappen. Der Aufwand sinkt so auf etwa O(n·log n + k) für k tatsächlich
|
||||
benachbarte Paare. Da sich die Körper zwischen zwei Schritten kaum bewegen, ist die Liste
|
||||
fast sortiert, und Pythons Timsort sortiert sie besonders schnell.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sandbox.physics.body import Body
|
||||
|
||||
|
||||
def find_candidate_pairs(bodies: list[Body]) -> list[tuple[Body, Body]]:
|
||||
"""Findet alle Körperpaare, deren umschließende Boxen sich überlappen oder berühren.
|
||||
|
||||
Paare aus zwei schlafenden Körpern werden übersprungen: Sie bewegen sich nicht, ihr
|
||||
Kontakt ändert sich also nicht. Jedes Paar ist nach ``id`` geordnet, damit die
|
||||
Reihenfolge (und damit die ganze Simulation) reproduzierbar ist.
|
||||
|
||||
Args:
|
||||
bodies: Die dynamischen Körper der Welt.
|
||||
|
||||
Returns:
|
||||
Kandidatenpaare für die genaue Prüfung mit :func:`~sandbox.physics.collision.collide`.
|
||||
"""
|
||||
entries = sorted(((body.bounds(), body) for body in bodies), key=_left_edge)
|
||||
pairs: list[tuple[Body, Body]] = []
|
||||
count = len(entries)
|
||||
for i in range(count):
|
||||
(_, min_y, max_x, max_y), body = entries[i]
|
||||
for j in range(i + 1, count):
|
||||
(other_min_x, other_min_y, _, other_max_y), other = entries[j]
|
||||
if other_min_x > max_x:
|
||||
break # Alle weiteren Boxen beginnen noch weiter rechts.
|
||||
if other_min_y > max_y or other_max_y < min_y:
|
||||
continue
|
||||
if body.sleeping and other.sleeping:
|
||||
continue
|
||||
pairs.append((body, other) if body.id < other.id else (other, body))
|
||||
return pairs
|
||||
|
||||
|
||||
def _left_edge(entry: tuple[tuple[float, float, float, float], Body]) -> float:
|
||||
"""Sortierschlüssel: linke Kante der umschließenden Box."""
|
||||
return entry[0][0]
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Kollisionserkennung ("Narrowphase"): Berühren sich zwei Körper, und wenn ja, wie?
|
||||
|
||||
Jede Testfunktion liefert entweder ``None`` (keine Berührung) oder einen :class:`Contact`
|
||||
mit drei Informationen, die die Kollisionsauflösung braucht:
|
||||
|
||||
* **Normale** n – Einheitsvektor, der von Körper A zu Körper B zeigt. Entlang n werden
|
||||
die Körper auseinandergedrückt.
|
||||
* **Eindringtiefe** – wie weit sich die Körper entlang n überlappen (≥ 0).
|
||||
* **Kontaktpunkt** – wo die Kraft angreift. Er bestimmt die Hebelarme für die Drehung.
|
||||
|
||||
Konvention: :func:`collide` ordnet die Körper so, dass Körper A die "einfachere" Form hat
|
||||
(Kreis vor Rechteck vor Wand). Für ein Körperpaar ist die Orientierung dadurch in jedem
|
||||
Schritt gleich, was das Warm-Starting des Solvers (Wiederverwenden alter Impulse) erlaubt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Final
|
||||
|
||||
from sandbox.config import NUMERIC_EPSILON
|
||||
from sandbox.physics.body import Body
|
||||
from sandbox.physics.shapes import Box, Circle, HalfPlane
|
||||
from sandbox.physics.vector import Vector2
|
||||
|
||||
#: Ersatznormale, wenn zwei Kreismittelpunkte exakt aufeinanderliegen und die Richtung
|
||||
#: daher unbestimmt ist. Jede Richtung wäre richtig; eine feste macht das Ergebnis
|
||||
#: reproduzierbar. (0, −1) schiebt Körper B nach oben, A nach unten.
|
||||
COINCIDENT_NORMAL: Final = Vector2(0.0, -1.0)
|
||||
|
||||
#: Reihenfolge der Formen für die kanonische Anordnung in :func:`collide`.
|
||||
_SHAPE_ORDER: Final = {Circle: 0, Box: 1, HalfPlane: 2}
|
||||
|
||||
|
||||
class Contact:
|
||||
"""Berührung zweier Körper in einem Simulationsschritt.
|
||||
|
||||
Attributes:
|
||||
body_a: Erster Körper.
|
||||
body_b: Zweiter Körper.
|
||||
normal: Einheitsnormale von A nach B.
|
||||
penetration: Eindringtiefe entlang der Normalen in px (≥ 0).
|
||||
point: Kontaktpunkt in Weltkoordinaten.
|
||||
normal_impulse: Vom Solver aufgebrachter Normalimpuls (Ergebnis, ≥ 0).
|
||||
tangent_impulse: Vom Solver aufgebrachter Reibungsimpuls (Ergebnis).
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"body_a",
|
||||
"body_b",
|
||||
"normal",
|
||||
"normal_impulse",
|
||||
"penetration",
|
||||
"point",
|
||||
"tangent_impulse",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, body_a: Body, body_b: Body, normal: Vector2, penetration: float, point: Vector2
|
||||
) -> None:
|
||||
self.body_a = body_a
|
||||
self.body_b = body_b
|
||||
self.normal = normal
|
||||
self.penetration = penetration
|
||||
self.point = point
|
||||
self.normal_impulse = 0.0
|
||||
self.tangent_impulse = 0.0
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[int, int]:
|
||||
"""Schlüssel des Körperpaares, um Kontakte über Schritte hinweg wiederzuerkennen."""
|
||||
return (self.body_a.id, self.body_b.id)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"Contact({self.body_a.id}->{self.body_b.id}, normal={self.normal!r}, "
|
||||
f"penetration={self.penetration:.3f})"
|
||||
)
|
||||
|
||||
|
||||
def collide(first: Body, second: Body) -> Contact | None:
|
||||
"""Prüft zwei beliebige Körper auf Berührung.
|
||||
|
||||
Die Körper werden vorher kanonisch angeordnet (siehe Moduldokumentation). Das Ergebnis
|
||||
kann ``body_a`` und ``body_b`` daher in vertauschter Reihenfolge enthalten.
|
||||
|
||||
Returns:
|
||||
Den Kontakt oder ``None``, wenn sich die Körper nicht berühren.
|
||||
"""
|
||||
body_a, body_b = first, second
|
||||
if _SHAPE_ORDER[type(body_b.shape)] < _SHAPE_ORDER[type(body_a.shape)]:
|
||||
body_a, body_b = body_b, body_a
|
||||
|
||||
shape_a = body_a.shape
|
||||
shape_b = body_b.shape
|
||||
if isinstance(shape_a, Circle):
|
||||
if isinstance(shape_b, Circle):
|
||||
return collide_circles(body_a, body_b)
|
||||
if isinstance(shape_b, Box):
|
||||
return collide_circle_box(body_a, body_b)
|
||||
return collide_circle_half_plane(body_a, body_b)
|
||||
if isinstance(shape_a, Box):
|
||||
if isinstance(shape_b, Box):
|
||||
return collide_boxes(body_a, body_b)
|
||||
return collide_box_half_plane(body_a, body_b)
|
||||
return None # Zwei Wände kollidieren nie miteinander.
|
||||
|
||||
|
||||
def collide_circles(body_a: Body, body_b: Body) -> Contact | None:
|
||||
"""Kreis gegen Kreis.
|
||||
|
||||
Zwei Kreise überlappen, wenn der Mittelpunktabstand kleiner als die Summe der Radien
|
||||
ist. Die Normale zeigt entlang der Verbindungslinie der Mittelpunkte.
|
||||
"""
|
||||
radius_a = body_a.shape.radius
|
||||
radius_b = body_b.shape.radius
|
||||
dx = body_b.position.x - body_a.position.x
|
||||
dy = body_b.position.y - body_a.position.y
|
||||
distance_sq = dx * dx + dy * dy
|
||||
radius_sum = radius_a + radius_b
|
||||
if distance_sq > radius_sum * radius_sum:
|
||||
return None
|
||||
|
||||
if distance_sq > NUMERIC_EPSILON * NUMERIC_EPSILON:
|
||||
distance = math.sqrt(distance_sq)
|
||||
normal = Vector2(dx / distance, dy / distance)
|
||||
else:
|
||||
distance = 0.0
|
||||
normal = COINCIDENT_NORMAL
|
||||
penetration = radius_sum - distance
|
||||
# Kontaktpunkt: Mitte des überlappenden Bereichs auf der Verbindungslinie.
|
||||
point = body_a.position + normal * (radius_a - 0.5 * penetration)
|
||||
return Contact(body_a, body_b, normal, penetration, point)
|
||||
|
||||
|
||||
def collide_circle_box(circle_body: Body, box_body: Body) -> Contact | None:
|
||||
"""Kreis (Körper A) gegen achsenparalleles Rechteck (Körper B).
|
||||
|
||||
Idee: Den Kreismittelpunkt komponentenweise auf das Rechteck klemmen. Das liefert den
|
||||
Punkt des Rechtecks, der dem Kreismittelpunkt am nächsten liegt. Ist er weniger als
|
||||
einen Radius entfernt, berühren sich die Körper.
|
||||
|
||||
Sonderfall: Liegt der Mittelpunkt *im* Rechteck, ist der geklemmte Punkt der
|
||||
Mittelpunkt selbst und liefert keine Richtung. Dann wird der Kreis über die nächste
|
||||
Kante hinausgeschoben (Achse mit der kleinsten Eindringtiefe).
|
||||
"""
|
||||
radius = circle_body.shape.radius
|
||||
half_width = box_body.shape.half_width
|
||||
half_height = box_body.shape.half_height
|
||||
box_x = box_body.position.x
|
||||
box_y = box_body.position.y
|
||||
# Kreismittelpunkt relativ zum Rechteckmittelpunkt.
|
||||
dx = circle_body.position.x - box_x
|
||||
dy = circle_body.position.y - box_y
|
||||
closest_x = min(max(dx, -half_width), half_width)
|
||||
closest_y = min(max(dy, -half_height), half_height)
|
||||
|
||||
if closest_x != dx or closest_y != dy:
|
||||
# Mittelpunkt außerhalb: Abstand zum nächsten Punkt des Rechtecks.
|
||||
offset_x = dx - closest_x
|
||||
offset_y = dy - closest_y
|
||||
distance_sq = offset_x * offset_x + offset_y * offset_y
|
||||
if distance_sq > radius * radius:
|
||||
return None
|
||||
distance = math.sqrt(distance_sq) # > 0, weil der Mittelpunkt außerhalb liegt
|
||||
# (offset) zeigt vom Rechteck zum Kreis; die Normale soll von A (Kreis) zu B zeigen.
|
||||
normal = Vector2(-offset_x / distance, -offset_y / distance)
|
||||
penetration = radius - distance
|
||||
point = Vector2(box_x + closest_x, box_y + closest_y)
|
||||
return Contact(circle_body, box_body, normal, penetration, point)
|
||||
|
||||
# Mittelpunkt innerhalb (oder genau auf dem Rand): über die nächste Kante hinausdrücken.
|
||||
gap_x = half_width - abs(dx)
|
||||
gap_y = half_height - abs(dy)
|
||||
if gap_x < gap_y:
|
||||
side = 1.0 if dx >= 0.0 else -1.0
|
||||
# Der Kreis muss in Richtung "side" hinaus, er bewegt sich entlang −n → n = −side.
|
||||
normal = Vector2(-side, 0.0)
|
||||
penetration = radius + gap_x
|
||||
point = Vector2(box_x + side * half_width, circle_body.position.y)
|
||||
else:
|
||||
side = 1.0 if dy >= 0.0 else -1.0
|
||||
normal = Vector2(0.0, -side)
|
||||
penetration = radius + gap_y
|
||||
point = Vector2(circle_body.position.x, box_y + side * half_height)
|
||||
return Contact(circle_body, box_body, normal, penetration, point)
|
||||
|
||||
|
||||
def collide_boxes(body_a: Body, body_b: Body) -> Contact | None:
|
||||
"""Rechteck gegen Rechteck (beide achsenparallel).
|
||||
|
||||
Zwei achsenparallele Rechtecke überlappen genau dann, wenn sich ihre Projektionen auf
|
||||
beide Achsen überlappen (Trennachsensatz). Getrennt wird entlang der Achse mit der
|
||||
kleineren Überlappung – das ist der kürzeste Weg aus der Durchdringung. Bei Gleichstand
|
||||
gewinnt die y-Achse, weil aufeinanderliegende Körper der häufigste Fall sind.
|
||||
"""
|
||||
shape_a = body_a.shape
|
||||
shape_b = body_b.shape
|
||||
dx = body_b.position.x - body_a.position.x
|
||||
dy = body_b.position.y - body_a.position.y
|
||||
overlap_x = shape_a.half_width + shape_b.half_width - abs(dx)
|
||||
if overlap_x < 0.0:
|
||||
return None
|
||||
overlap_y = shape_a.half_height + shape_b.half_height - abs(dy)
|
||||
if overlap_y < 0.0:
|
||||
return None
|
||||
|
||||
if overlap_x < overlap_y:
|
||||
normal = Vector2(1.0 if dx >= 0.0 else -1.0, 0.0)
|
||||
penetration = overlap_x
|
||||
else:
|
||||
normal = Vector2(0.0, 1.0 if dy >= 0.0 else -1.0)
|
||||
penetration = overlap_y
|
||||
|
||||
# Kontaktpunkt: Mittelpunkt des Überlappungsrechtecks. Da Rechtecke sich nie drehen,
|
||||
# beeinflusst er nur die Debug-Anzeige, nicht die Physik.
|
||||
a_x = body_a.position.x
|
||||
a_y = body_a.position.y
|
||||
b_x = body_b.position.x
|
||||
b_y = body_b.position.y
|
||||
left = max(a_x - shape_a.half_width, b_x - shape_b.half_width)
|
||||
right = min(a_x + shape_a.half_width, b_x + shape_b.half_width)
|
||||
top = max(a_y - shape_a.half_height, b_y - shape_b.half_height)
|
||||
bottom = min(a_y + shape_a.half_height, b_y + shape_b.half_height)
|
||||
point = Vector2(0.5 * (left + right), 0.5 * (top + bottom))
|
||||
return Contact(body_a, body_b, normal, penetration, point)
|
||||
|
||||
|
||||
def collide_circle_half_plane(circle_body: Body, wall_body: Body) -> Contact | None:
|
||||
"""Kreis gegen Wand (Halbebene).
|
||||
|
||||
Der Kreis berührt die Wand, wenn der Abstand seines Mittelpunkts zur Wandlinie kleiner
|
||||
als der Radius ist. Liegt der Mittelpunkt sogar hinter der Wand, ist der Abstand
|
||||
negativ und die Eindringtiefe entsprechend groß – der Kreis wird trotzdem korrekt in
|
||||
den freien Raum zurückgeschoben.
|
||||
"""
|
||||
wall = wall_body.shape
|
||||
center = circle_body.position
|
||||
distance = wall.distance(center)
|
||||
penetration = circle_body.shape.radius - distance
|
||||
if penetration < 0.0:
|
||||
return None
|
||||
# Die Wandnormale zeigt in den freien Raum, also zum Kreis; A→B ist die Gegenrichtung.
|
||||
normal = -wall.normal
|
||||
point = center - wall.normal * distance # Lotfußpunkt auf der Wandlinie
|
||||
return Contact(circle_body, wall_body, normal, penetration, point)
|
||||
|
||||
|
||||
def collide_box_half_plane(box_body: Body, wall_body: Body) -> Contact | None:
|
||||
"""Rechteck gegen Wand (Halbebene).
|
||||
|
||||
Die "Reichweite" eines achsenparallelen Rechtecks entlang der Normalen n ist
|
||||
``|n.x|·hw + |n.y|·hh`` (Projektion der Halbdiagonale). Ist der Mittelpunkt näher an der
|
||||
Wand als diese Reichweite, ragt das Rechteck in die Wand.
|
||||
"""
|
||||
wall = wall_body.shape
|
||||
shape = box_body.shape
|
||||
center = box_body.position
|
||||
reach = abs(wall.normal.x) * shape.half_width + abs(wall.normal.y) * shape.half_height
|
||||
distance = wall.distance(center)
|
||||
penetration = reach - distance
|
||||
if penetration < 0.0:
|
||||
return None
|
||||
normal = -wall.normal
|
||||
point = center - wall.normal * distance
|
||||
return Contact(box_body, wall_body, normal, penetration, point)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Geometrische Formen der Körper: Kreis, achsenparalleles Rechteck und Halbebene.
|
||||
|
||||
Formen beschreiben nur die Geometrie relativ zum Schwerpunkt. Position, Geschwindigkeit
|
||||
und Masse gehören zum :class:`~sandbox.physics.body.Body`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
from sandbox.config import NUMERIC_EPSILON
|
||||
from sandbox.physics.vector import Vector2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Circle:
|
||||
"""Homogene Kreisscheibe. Kreise können sich drehen (z. B. rollen).
|
||||
|
||||
Attributes:
|
||||
radius: Radius in px (> 0).
|
||||
"""
|
||||
|
||||
radius: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.radius > 0.0:
|
||||
raise ValueError(f"Radius muss positiv sein, ist aber {self.radius}")
|
||||
|
||||
def area(self) -> float:
|
||||
"""Fläche ``π·r²`` – bestimmt zusammen mit der Dichte die Masse."""
|
||||
return math.pi * self.radius * self.radius
|
||||
|
||||
def moment_of_inertia(self, mass: float) -> float:
|
||||
"""Trägheitsmoment einer homogenen Scheibe um ihren Mittelpunkt: ``I = ½·m·r²``.
|
||||
|
||||
Herleitung: Die Scheibe besteht aus dünnen Ringen mit Radius s und Masse
|
||||
``dm = m/(π·r²) · 2π·s·ds``. Jeder Ring trägt ``s²·dm`` bei, also
|
||||
``I = (2m/r²)·∫₀ʳ s³ ds = (2m/r²)·r⁴/4 = ½·m·r²``.
|
||||
"""
|
||||
return 0.5 * mass * self.radius * self.radius
|
||||
|
||||
def half_extents(self) -> tuple[float, float]:
|
||||
"""Halbe Breite und Höhe der umschließenden achsenparallelen Box."""
|
||||
return (self.radius, self.radius)
|
||||
|
||||
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
|
||||
"""Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Kreis liegt."""
|
||||
reach = self.radius + tolerance
|
||||
return offset.length_squared() <= reach * reach
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Box:
|
||||
"""Achsenparalleles Rechteck (AABB).
|
||||
|
||||
Rechtecke bleiben per Definition achsenparallel. Deshalb liefert
|
||||
:meth:`moment_of_inertia` "unendlich": Das inverse Trägheitsmoment ist dann 0, und kein
|
||||
Drehimpuls kann das Rechteck je drehen.
|
||||
|
||||
Attributes:
|
||||
half_width: Halbe Breite in px (> 0).
|
||||
half_height: Halbe Höhe in px (> 0).
|
||||
"""
|
||||
|
||||
half_width: float
|
||||
half_height: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not (self.half_width > 0.0 and self.half_height > 0.0):
|
||||
raise ValueError(
|
||||
f"Halbe Kantenlängen müssen positiv sein: {self.half_width}, {self.half_height}"
|
||||
)
|
||||
|
||||
def area(self) -> float:
|
||||
"""Fläche ``(2·hw)·(2·hh)``."""
|
||||
return 4.0 * self.half_width * self.half_height
|
||||
|
||||
def moment_of_inertia(self, mass: float) -> float:
|
||||
"""Unendlich, damit sich das Rechteck nie dreht (siehe Klassenbeschreibung).
|
||||
|
||||
``mass`` wird nur für eine einheitliche Schnittstelle mit :class:`Circle` angenommen.
|
||||
"""
|
||||
del mass
|
||||
return math.inf
|
||||
|
||||
def half_extents(self) -> tuple[float, float]:
|
||||
"""Halbe Breite und Höhe."""
|
||||
return (self.half_width, self.half_height)
|
||||
|
||||
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
|
||||
"""Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Rechteck liegt."""
|
||||
return (
|
||||
abs(offset.x) <= self.half_width + tolerance
|
||||
and abs(offset.y) <= self.half_height + tolerance
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HalfPlane:
|
||||
"""Unendliche Halbebene – modelliert Boden, Decke und Seitenwände.
|
||||
|
||||
Die Ebene ist durch ``normal·p = offset`` gegeben. Die Normale zeigt in den freien
|
||||
Raum; alles mit ``normal·p < offset`` ist "in der Wand". Weil eine Halbebene unendlich
|
||||
dick ist, kann kein Körper durch sie hindurchtunneln: Selbst ein Körper, der in einem
|
||||
Schritt komplett hinter die Wandlinie springt, wird als (tiefe) Durchdringung erkannt
|
||||
und zurückgeschoben.
|
||||
|
||||
Attributes:
|
||||
normal: Einheitsnormale, zeigt in den freien Raum.
|
||||
offset: Abstand der Ebene vom Ursprung entlang der Normalen.
|
||||
"""
|
||||
|
||||
normal: Vector2
|
||||
offset: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if abs(self.normal.length() - 1.0) > NUMERIC_EPSILON:
|
||||
raise ValueError(f"Die Normale muss Länge 1 haben: {self.normal}")
|
||||
|
||||
def distance(self, point: Vector2) -> float:
|
||||
"""Vorzeichenbehafteter Abstand eines Punktes (positiv = im freien Raum)."""
|
||||
return self.normal.dot(point) - self.offset
|
||||
|
||||
def half_extents(self) -> tuple[float, float]:
|
||||
"""Eine Halbebene ist unbegrenzt."""
|
||||
return (math.inf, math.inf)
|
||||
|
||||
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
|
||||
"""Prüft, ob ein Punkt in der Wand (hinter der Ebene) liegt.
|
||||
|
||||
Wandkörper liegen im Ursprung, deshalb ist ``offset`` hier der Punkt selbst.
|
||||
"""
|
||||
return self.distance(offset) <= tolerance
|
||||
|
||||
|
||||
#: Alle unterstützten Formen.
|
||||
Shape: TypeAlias = Circle | Box | HalfPlane
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Schlafmodus: ruhende Körper einfrieren, bis etwas sie anstößt.
|
||||
|
||||
Warum: Ein Solver mit endlich vielen Iterationen lässt immer winzige Restbewegungen übrig.
|
||||
Ein Stapel "atmet" dadurch sichtbar oder kriecht. Friert man Körper ein, die eine Weile
|
||||
praktisch stillstehen, liegen sie wirklich ruhig – und sparen Rechenzeit, weil sie weder
|
||||
integriert noch aufgelöst werden müssen.
|
||||
|
||||
Warum gruppenweise ("Inseln")? Würde jeder Körper einzeln einschlafen, könnte ein
|
||||
schlafender Körper unter einem wachen liegen: Der wache weckt ihn, beide ruhen, der eine
|
||||
schläft früher ein, der andere weckt ihn wieder – endloses Hin und Her. Deshalb werden
|
||||
Körper, die sich (direkt oder über andere) berühren, zu einer Insel zusammengefasst, und
|
||||
eine Insel schläft nur als Ganzes ein – und wacht auch nur als Ganzes wieder auf. Wände
|
||||
verbinden keine Inseln, sonst wäre alles auf dem Boden eine einzige Insel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sandbox.config import PhysicsConfig
|
||||
from sandbox.physics.body import Body
|
||||
from sandbox.physics.collision import Contact
|
||||
|
||||
|
||||
class UnionFind:
|
||||
"""Disjunkte Mengen ("Union-Find") zum Bilden von Kontakt-Inseln.
|
||||
|
||||
Jede Menge wird durch einen Repräsentanten identifiziert. :meth:`union` verschmilzt zwei
|
||||
Mengen, :meth:`find` liefert den Repräsentanten – dank Pfadverkürzung in nahezu
|
||||
konstanter Zeit.
|
||||
"""
|
||||
|
||||
def __init__(self, keys: list[int]) -> None:
|
||||
self._parent = {key: key for key in keys}
|
||||
|
||||
def find(self, key: int) -> int:
|
||||
"""Repräsentant der Menge, zu der ``key`` gehört."""
|
||||
root = key
|
||||
while self._parent[root] != root:
|
||||
root = self._parent[root]
|
||||
# Pfadverkürzung: alle besuchten Elemente direkt an die Wurzel hängen.
|
||||
while self._parent[key] != root:
|
||||
next_key = self._parent[key]
|
||||
self._parent[key] = root
|
||||
key = next_key
|
||||
return root
|
||||
|
||||
def union(self, first: int, second: int) -> None:
|
||||
"""Verschmilzt die Mengen von ``first`` und ``second``."""
|
||||
root_first = self.find(first)
|
||||
root_second = self.find(second)
|
||||
if root_first != root_second:
|
||||
self._parent[root_second] = root_first
|
||||
|
||||
|
||||
def wake_touched_bodies(contacts: list[Contact]) -> list[Body]:
|
||||
"""Weckt schlafende Körper, die von einem wachen dynamischen Körper berührt werden.
|
||||
|
||||
Mit dem berührten Körper wacht seine ganze Schlaf-Insel auf (siehe
|
||||
:meth:`Body.wake <sandbox.physics.body.Body.wake>`). Die geweckten Körper gehören ab
|
||||
sofort zur Insel des wachen Körpers und schlafen erst wieder ein, wenn die ganze Insel
|
||||
zur Ruhe gekommen ist.
|
||||
|
||||
Returns:
|
||||
Alle in diesem Aufruf geweckten Körper.
|
||||
"""
|
||||
woken: list[Body] = []
|
||||
for contact in contacts:
|
||||
body_a = contact.body_a
|
||||
body_b = contact.body_b
|
||||
if body_a.sleeping == body_b.sleeping:
|
||||
continue # beide wach, beide schlafend – oder schon von einer Insel geweckt
|
||||
sleeper, other = (body_a, body_b) if body_a.sleeping else (body_b, body_a)
|
||||
if other.is_static:
|
||||
continue # Wände wecken niemanden – sonst schliefe nichts am Boden je ein.
|
||||
island = sleeper.sleep_island
|
||||
sleeper.wake()
|
||||
woken.extend(island if island is not None else (sleeper,))
|
||||
return woken
|
||||
|
||||
|
||||
def find_islands(bodies: list[Body], contacts: list[Contact]) -> list[list[Body]]:
|
||||
"""Gruppiert wache dynamische Körper nach Berührung zu Inseln.
|
||||
|
||||
Args:
|
||||
bodies: Die wachen dynamischen Körper.
|
||||
contacts: Die Kontakte des aktuellen Schritts.
|
||||
|
||||
Returns:
|
||||
Inseln in der Reihenfolge ihres ersten Körpers in ``bodies``.
|
||||
"""
|
||||
union_find = UnionFind([body.id for body in bodies])
|
||||
for contact in contacts:
|
||||
if contact.body_a.is_static or contact.body_b.is_static:
|
||||
continue
|
||||
union_find.union(contact.body_a.id, contact.body_b.id)
|
||||
|
||||
islands: dict[int, list[Body]] = {}
|
||||
for body in bodies:
|
||||
islands.setdefault(union_find.find(body.id), []).append(body)
|
||||
return list(islands.values())
|
||||
|
||||
|
||||
def update_sleep(
|
||||
bodies: list[Body], contacts: list[Contact], dt: float, config: PhysicsConfig
|
||||
) -> None:
|
||||
"""Aktualisiert die Ruhezeiten und schickt vollständig ruhende Inseln schlafen.
|
||||
|
||||
Args:
|
||||
bodies: Die wachen dynamischen Körper dieses Schritts.
|
||||
contacts: Die Kontakte dieses Schritts.
|
||||
dt: Zeitschritt in s.
|
||||
config: Schwellwerte für Ruhe und Einschlafzeit.
|
||||
"""
|
||||
linear_limit_sq = config.sleep_linear_speed * config.sleep_linear_speed
|
||||
angular_limit = config.sleep_angular_speed
|
||||
for body in bodies:
|
||||
resting = (
|
||||
not body.held
|
||||
and body.velocity.length_squared() <= linear_limit_sq
|
||||
and abs(body.angular_velocity) <= angular_limit
|
||||
)
|
||||
body.sleep_time = body.sleep_time + dt if resting else 0.0
|
||||
|
||||
for island in find_islands(bodies, contacts):
|
||||
if all(body.sleep_time >= config.time_to_sleep for body in island):
|
||||
for body in island:
|
||||
body.put_to_sleep(island) # alle teilen sich dieselbe Liste
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Impulsbasierte Kollisionsauflösung ("Sequential Impulses") und Positionskorrektur.
|
||||
|
||||
Bedingungen pro Kontakt
|
||||
-----------------------
|
||||
1. **Nichtdurchdringung** entlang der Normalen n: Am Kontaktpunkt dürfen sich die Körper
|
||||
nicht weiter annähern, ``v_n ≥ 0``. Bei einem echten Aufprall gilt zusätzlich Newtons
|
||||
Stoßgesetz ``v_n' = −e·v_n`` (e = Restitution).
|
||||
2. **Reibung** entlang der Tangente t: Das Gleiten ``v_t`` soll verschwinden, der
|
||||
Reibungsimpuls ist aber nach Coulomb auf ``|P_t| ≤ μ·P_n`` begrenzt. Reicht das nicht,
|
||||
rutschen die Körper weiter (Gleitreibung).
|
||||
|
||||
Herleitung des Kollisionsimpulses
|
||||
---------------------------------
|
||||
Relativgeschwindigkeit des Kontaktpunkts (B relativ zu A) mit den Hebelarmen r_A, r_B vom
|
||||
Schwerpunkt zum Kontaktpunkt und ``ω × r = ω·(−r.y, r.x)``::
|
||||
|
||||
v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), v_n = v_rel · n
|
||||
|
||||
Ein Impuls ``P = λ·n`` wirkt auf B positiv und auf A negativ (actio = reactio)::
|
||||
|
||||
v_A' = v_A − λ·n/m_A ω_A' = ω_A − (r_A × λn)/I_A
|
||||
v_B' = v_B + λ·n/m_B ω_B' = ω_B + (r_B × λn)/I_B
|
||||
|
||||
Eingesetzt ändert sich die Normalgeschwindigkeit linear in λ::
|
||||
|
||||
v_n' = v_n + λ·K, K = 1/m_A + 1/m_B + (r_A × n)²/I_A + (r_B × n)²/I_B
|
||||
|
||||
Aus ``v_n' = −e·v_n`` folgt ``λ = −(1 + e)·v_n / K``. Der Kehrwert 1/K ist die *effektive
|
||||
Masse* des Kontakts: die träge Masse, die der Stoß entlang n "spürt". Für die Reibung gilt
|
||||
dasselbe mit t statt n und dem Ziel ``v_t' = 0``.
|
||||
|
||||
In dieser Engine verschwinden die Drehterme von K für die Normale: Bei Kreisen geht die
|
||||
Normale durch den Mittelpunkt (r ∥ n, also r × n = 0), und Rechtecke drehen sich nie
|
||||
(1/I = 0). Normalimpulse erzeugen daher kein Drehmoment – nur die Reibung bringt Kreise
|
||||
zum Rollen.
|
||||
|
||||
Rollwiderstand
|
||||
--------------
|
||||
Reines Rollen verbraucht keine Reibungsenergie: Ohne weitere Kraft rollte ein Kreis ewig,
|
||||
und ein Rechteck auf einem Kreis führe darauf wie auf einer Rolle davon. Real verformen
|
||||
sich Kreis und Unterlage ein wenig, was ein bremsendes Moment ``M ≤ μ_r·N·r`` erzeugt
|
||||
(μ_r = Rollwiderstandsbeiwert, N = Normalkraft, r = Radius). Pro Schritt entspricht das
|
||||
einem Drehimpuls ``≤ μ_r·P_n·r``. Umgesetzt ist es wie die Reibung als begrenzter Impuls,
|
||||
nur auf die relative Winkelgeschwindigkeit ``ω_B − ω_A`` mit der effektiven Masse
|
||||
``1/(1/I_A + 1/I_B)``. Weil die Grenze mit der Auflagekraft wächst, kommen schwer
|
||||
belastete "Rollen" schnell zur Ruhe, während ein frei rollender Ball weit rollt.
|
||||
|
||||
Warum iterativ?
|
||||
---------------
|
||||
Liegt ein Körper auf mehreren anderen (Stapel), beeinflussen sich die Kontakte gegenseitig.
|
||||
Statt ein Gleichungssystem zu lösen, werden die Kontakte reihum mehrfach aufgelöst
|
||||
(Gauß-Seidel-Verfahren). Geklemmt wird dabei der über alle Durchläufe *akkumulierte*
|
||||
Impuls (≥ 0), nicht der einzelne Teilimpuls: Ein späterer Durchlauf darf einen zu großen
|
||||
früheren Impuls wieder zurücknehmen. So konvergiert das Verfahren gegen die physikalische
|
||||
Lösung, in der Kontakte nur drücken, aber nie ziehen.
|
||||
|
||||
Warm-Starting: Die akkumulierten Impulse des vorigen Schritts sind ein sehr guter
|
||||
Startwert, weil sich ruhende Kontakte kaum ändern. Ein Stapel steht so schon nach wenigen
|
||||
Durchläufen stabil, statt jeden Schritt bei 0 beginnen zu müssen.
|
||||
|
||||
Leistung: Die inneren Schleifen laufen tausendfach pro Schritt. Deshalb arbeiten sie auf
|
||||
lokalen Float-Listen statt auf Vektorobjekten; die Formeln sind dieselben wie oben,
|
||||
komponentenweise ausgeschrieben.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TypeAlias
|
||||
|
||||
from sandbox.config import PhysicsConfig
|
||||
from sandbox.physics.body import Body
|
||||
from sandbox.physics.collision import Contact
|
||||
from sandbox.physics.vector import Vector2
|
||||
|
||||
#: Gespeicherte Impulse eines Kontakts für das Warm-Starting:
|
||||
#: (Normalimpuls, Reibungsimpuls, Rollwiderstandsimpuls, Normale x, Normale y).
|
||||
_CachedImpulses: TypeAlias = tuple[float, float, float, float, float]
|
||||
|
||||
|
||||
class _Constraint:
|
||||
"""Vorberechnete Solver-Daten eines Kontakts (nur Floats, für schnelle Iterationen)."""
|
||||
|
||||
__slots__ = (
|
||||
"contact",
|
||||
"friction",
|
||||
"index_a",
|
||||
"index_b",
|
||||
"inv_inertia_a",
|
||||
"inv_inertia_b",
|
||||
"inv_mass_a",
|
||||
"inv_mass_b",
|
||||
"normal_impulse",
|
||||
"normal_mass",
|
||||
"normal_x",
|
||||
"normal_y",
|
||||
"offset_a_x",
|
||||
"offset_a_y",
|
||||
"offset_b_x",
|
||||
"offset_b_y",
|
||||
"penetration",
|
||||
"rolling_impulse",
|
||||
"rolling_limit",
|
||||
"rolling_mass",
|
||||
"tangent_impulse",
|
||||
"tangent_mass",
|
||||
"tangent_x",
|
||||
"tangent_y",
|
||||
"velocity_bias",
|
||||
)
|
||||
|
||||
contact: Contact
|
||||
friction: float
|
||||
index_a: int
|
||||
index_b: int
|
||||
inv_inertia_a: float
|
||||
inv_inertia_b: float
|
||||
inv_mass_a: float
|
||||
inv_mass_b: float
|
||||
normal_impulse: float
|
||||
normal_mass: float
|
||||
normal_x: float
|
||||
normal_y: float
|
||||
offset_a_x: float
|
||||
offset_a_y: float
|
||||
offset_b_x: float
|
||||
offset_b_y: float
|
||||
penetration: float
|
||||
rolling_impulse: float
|
||||
rolling_limit: float
|
||||
rolling_mass: float
|
||||
tangent_impulse: float
|
||||
tangent_mass: float
|
||||
tangent_x: float
|
||||
tangent_y: float
|
||||
velocity_bias: float
|
||||
|
||||
|
||||
class ContactSolver:
|
||||
"""Löst Kontakte über Impulse auf und korrigiert verbleibende Durchdringungen.
|
||||
|
||||
Ablauf pro Simulationsschritt (gesteuert von :class:`~sandbox.physics.world.World`):
|
||||
|
||||
1. :meth:`solve_velocities` – Geschwindigkeiten so ändern, dass kein Kontakt sich weiter
|
||||
schließt (mit Restitution und Reibung).
|
||||
2. Die Welt integriert die Positionen mit den neuen Geschwindigkeiten.
|
||||
3. :meth:`correct_positions` – verbliebene Überlappungen direkt über die Positionen
|
||||
abbauen, ohne Geschwindigkeiten zu ändern.
|
||||
"""
|
||||
|
||||
def __init__(self, config: PhysicsConfig) -> None:
|
||||
self._config = config
|
||||
self._constraints: list[_Constraint] = []
|
||||
self._bodies: list[Body] = []
|
||||
self._velocity_x: list[float] = []
|
||||
self._velocity_y: list[float] = []
|
||||
self._angular_velocity: list[float] = []
|
||||
self._start_x: list[float] = []
|
||||
self._start_y: list[float] = []
|
||||
# Akkumulierte Impulse des letzten Schritts je Körperpaar (für das Warm-Starting).
|
||||
self._impulse_cache: dict[tuple[int, int], _CachedImpulses] = {}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Vergisst alle gespeicherten Impulse (z. B. nachdem die Welt geleert wurde)."""
|
||||
self._constraints = []
|
||||
self._bodies = []
|
||||
self._impulse_cache = {}
|
||||
|
||||
# ------------------------------------------------------------------ Geschwindigkeiten
|
||||
|
||||
def solve_velocities(self, contacts: list[Contact]) -> None:
|
||||
"""Berechnet die Kontaktimpulse und schreibt die neuen Geschwindigkeiten zurück.
|
||||
|
||||
Nach dem Aufruf enthalten die Kontakte die aufgebrachten Impulse
|
||||
(``normal_impulse``, ``tangent_impulse``).
|
||||
"""
|
||||
self._prepare(contacts)
|
||||
if self._config.warm_starting:
|
||||
self._warm_start()
|
||||
for _ in range(self._config.velocity_iterations):
|
||||
self._solve_velocity_iteration()
|
||||
self._store_velocities()
|
||||
|
||||
def _prepare(self, contacts: list[Contact]) -> None:
|
||||
"""Legt die Float-Arbeitskopien an und berechnet die effektiven Massen."""
|
||||
bodies: list[Body] = []
|
||||
index_of: dict[int, int] = {}
|
||||
index_pairs: list[tuple[int, int]] = []
|
||||
for contact in contacts:
|
||||
indices = []
|
||||
for body in (contact.body_a, contact.body_b):
|
||||
index = index_of.get(body.id)
|
||||
if index is None:
|
||||
index = len(bodies)
|
||||
index_of[body.id] = index
|
||||
bodies.append(body)
|
||||
indices.append(index)
|
||||
index_pairs.append((indices[0], indices[1]))
|
||||
|
||||
self._bodies = bodies
|
||||
self._velocity_x = [body.velocity.x for body in bodies]
|
||||
self._velocity_y = [body.velocity.y for body in bodies]
|
||||
self._angular_velocity = [body.angular_velocity for body in bodies]
|
||||
# Positionen bei der Kollisionserkennung – Bezug für die spätere Positionskorrektur.
|
||||
self._start_x = [body.position.x for body in bodies]
|
||||
self._start_y = [body.position.y for body in bodies]
|
||||
self._constraints = [
|
||||
self._make_constraint(contact, index_a, index_b)
|
||||
for contact, (index_a, index_b) in zip(contacts, index_pairs, strict=True)
|
||||
]
|
||||
|
||||
def _make_constraint(self, contact: Contact, index_a: int, index_b: int) -> _Constraint:
|
||||
"""Berechnet alle Größen eines Kontakts, die sich während der Iterationen nicht ändern.
|
||||
|
||||
Läuft für jeden Kontakt in jedem Schritt und rechnet deshalb – wie die Iterationen –
|
||||
komponentenweise mit Floats statt mit temporären Vektorobjekten.
|
||||
"""
|
||||
body_a = contact.body_a
|
||||
body_b = contact.body_b
|
||||
normal_x = contact.normal.x
|
||||
normal_y = contact.normal.y
|
||||
# Tangente = Normale um 90° gedreht (−n.y, n.x). Ihr Vorzeichen ist beliebig, weil
|
||||
# der Reibungsimpuls symmetrisch auf [−μ·P_n, +μ·P_n] geklemmt wird.
|
||||
tangent_x = -normal_y
|
||||
tangent_y = normal_x
|
||||
# Hebelarme r_A, r_B: vom jeweiligen Schwerpunkt zum Kontaktpunkt.
|
||||
point = contact.point
|
||||
offset_a_x = point.x - body_a.position.x
|
||||
offset_a_y = point.y - body_a.position.y
|
||||
offset_b_x = point.x - body_b.position.x
|
||||
offset_b_y = point.y - body_b.position.y
|
||||
|
||||
constraint = _Constraint()
|
||||
constraint.contact = contact
|
||||
constraint.index_a = index_a
|
||||
constraint.index_b = index_b
|
||||
constraint.normal_x = normal_x
|
||||
constraint.normal_y = normal_y
|
||||
constraint.tangent_x = tangent_x
|
||||
constraint.tangent_y = tangent_y
|
||||
constraint.offset_a_x = offset_a_x
|
||||
constraint.offset_a_y = offset_a_y
|
||||
constraint.offset_b_x = offset_b_x
|
||||
constraint.offset_b_y = offset_b_y
|
||||
constraint.inv_mass_a = body_a.inv_mass
|
||||
constraint.inv_mass_b = body_b.inv_mass
|
||||
constraint.inv_inertia_a = body_a.inv_inertia
|
||||
constraint.inv_inertia_b = body_b.inv_inertia
|
||||
|
||||
# Effektive Massen 1/K (siehe Moduldokumentation). Für die Normale entfallen die
|
||||
# Drehterme, weil in dieser Engine stets r × n = 0 oder 1/I = 0 gilt.
|
||||
inv_mass_sum = body_a.inv_mass + body_b.inv_mass
|
||||
constraint.normal_mass = 1.0 / inv_mass_sum if inv_mass_sum > 0.0 else 0.0
|
||||
arm_a = offset_a_x * tangent_y - offset_a_y * tangent_x # r_A × t
|
||||
arm_b = offset_b_x * tangent_y - offset_b_y * tangent_x # r_B × t
|
||||
k_tangent = (
|
||||
inv_mass_sum + body_a.inv_inertia * arm_a * arm_a + body_b.inv_inertia * arm_b * arm_b
|
||||
)
|
||||
constraint.tangent_mass = 1.0 / k_tangent if k_tangent > 0.0 else 0.0
|
||||
|
||||
# Rollwiderstand (siehe Moduldokumentation): nur, wenn sich mindestens ein Körper
|
||||
# drehen kann. Als Radius dient der längere Hebelarm eines drehbaren Körpers.
|
||||
inv_inertia_sum = body_a.inv_inertia + body_b.inv_inertia
|
||||
if inv_inertia_sum > 0.0:
|
||||
lever = max(
|
||||
math.hypot(offset_a_x, offset_a_y) if body_a.can_rotate else 0.0,
|
||||
math.hypot(offset_b_x, offset_b_y) if body_b.can_rotate else 0.0,
|
||||
)
|
||||
constraint.rolling_mass = 1.0 / inv_inertia_sum
|
||||
constraint.rolling_limit = self._config.rolling_resistance * lever
|
||||
else:
|
||||
constraint.rolling_mass = 0.0
|
||||
constraint.rolling_limit = 0.0
|
||||
|
||||
# Materialwerte mischen: Die Reibung als geometrisches Mittel (ein glatter Partner
|
||||
# macht den Kontakt glatt), die Restitution als Maximum (ein Gummiball springt auch
|
||||
# von einem Holzklotz ab).
|
||||
constraint.friction = math.sqrt(body_a.material.friction * body_b.material.friction)
|
||||
restitution = max(body_a.material.restitution, body_b.material.restitution)
|
||||
|
||||
# Abprallen nach Newton: v_n' = −e·v_n. Die Aufprallgeschwindigkeit v_n stammt vom
|
||||
# Beginn des Schritts – vor Schwerkraft und Warm-Start. Mit der Geschwindigkeit nach
|
||||
# der Schwerkraft prallte ein Ball mit e·(v + g·dt) statt e·v ab und gewänne bei
|
||||
# e = 1 mit jedem Aufprall Höhe (Energie aus dem Nichts).
|
||||
velocity_a = body_a.previous_velocity
|
||||
velocity_b = body_b.previous_velocity
|
||||
normal_speed = (velocity_b.x - velocity_a.x) * normal_x + (
|
||||
velocity_b.y - velocity_a.y
|
||||
) * normal_y
|
||||
# Kein Abprallen, (1) unterhalb der Schwelle: Aufliegende Körper würden sonst durch
|
||||
# winzige Dauer-Hüpfer zittern; (2) wenn ein Körper gehalten wird: Die Hand fängt
|
||||
# den Stoß ab. Sonst prallte ein gegen ein Hindernis gedrückter Körper in jedem
|
||||
# Schritt ab und würde gleich wieder hingezogen – er zitterte.
|
||||
bounces = normal_speed < -self._config.restitution_threshold
|
||||
if bounces and not (body_a.held or body_b.held):
|
||||
constraint.velocity_bias = -restitution * normal_speed
|
||||
else:
|
||||
constraint.velocity_bias = 0.0
|
||||
|
||||
constraint.normal_impulse = 0.0
|
||||
constraint.tangent_impulse = 0.0
|
||||
constraint.rolling_impulse = 0.0
|
||||
constraint.penetration = contact.penetration
|
||||
return constraint
|
||||
|
||||
def _warm_start(self) -> None:
|
||||
"""Wendet die Impulse des letzten Schritts erneut an (bessere Startwerte)."""
|
||||
cache = self._impulse_cache
|
||||
min_alignment = self._config.warm_start_min_alignment
|
||||
velocity_x = self._velocity_x
|
||||
velocity_y = self._velocity_y
|
||||
angular = self._angular_velocity
|
||||
for c in self._constraints:
|
||||
cached = cache.get(c.contact.key)
|
||||
if cached is None:
|
||||
continue
|
||||
normal_impulse, tangent_impulse, rolling_impulse, old_normal_x, old_normal_y = cached
|
||||
if old_normal_x * c.normal_x + old_normal_y * c.normal_y < min_alignment:
|
||||
continue # Normale hat sich zu stark gedreht, der alte Impuls passt nicht.
|
||||
c.normal_impulse = normal_impulse
|
||||
c.tangent_impulse = tangent_impulse
|
||||
c.rolling_impulse = rolling_impulse
|
||||
impulse_x = normal_impulse * c.normal_x + tangent_impulse * c.tangent_x
|
||||
impulse_y = normal_impulse * c.normal_y + tangent_impulse * c.tangent_y
|
||||
a = c.index_a
|
||||
b = c.index_b
|
||||
velocity_x[a] -= impulse_x * c.inv_mass_a
|
||||
velocity_y[a] -= impulse_y * c.inv_mass_a
|
||||
angular[a] -= c.inv_inertia_a * (
|
||||
c.offset_a_x * impulse_y - c.offset_a_y * impulse_x + rolling_impulse
|
||||
)
|
||||
velocity_x[b] += impulse_x * c.inv_mass_b
|
||||
velocity_y[b] += impulse_y * c.inv_mass_b
|
||||
angular[b] += c.inv_inertia_b * (
|
||||
c.offset_b_x * impulse_y - c.offset_b_y * impulse_x + rolling_impulse
|
||||
)
|
||||
|
||||
def _solve_velocity_iteration(self) -> None:
|
||||
"""Ein Gauß-Seidel-Durchlauf über alle Kontakte.
|
||||
|
||||
Reihenfolge je Kontakt: Normale, dann Reibung, dann Rollwiderstand. Reibung und
|
||||
Rollwiderstand sind durch den Normalimpuls begrenzt; werden sie danach gelöst, gilt
|
||||
ihre Grenze nach jedem Durchlauf exakt für den aktuellen Normalimpuls. (Andersherum
|
||||
könnte der Normalimpuls hinterher noch sinken und die Reibung über der
|
||||
Coulomb-Grenze zurücklassen.)
|
||||
"""
|
||||
velocity_x = self._velocity_x
|
||||
velocity_y = self._velocity_y
|
||||
angular = self._angular_velocity
|
||||
for c in self._constraints:
|
||||
a = c.index_a
|
||||
b = c.index_b
|
||||
|
||||
# --- Normale: Drehterme entfallen hier (siehe Moduldokumentation).
|
||||
normal_speed = (velocity_x[b] - velocity_x[a]) * c.normal_x + (
|
||||
velocity_y[b] - velocity_y[a]
|
||||
) * c.normal_y
|
||||
# λ = (Ziel − v_n)/K; Ziel ist die Abprallgeschwindigkeit (oder 0).
|
||||
old_impulse = c.normal_impulse
|
||||
new_impulse = old_impulse + (c.velocity_bias - normal_speed) * c.normal_mass
|
||||
if new_impulse < 0.0:
|
||||
new_impulse = 0.0 # Kontakte können nur drücken, nie ziehen.
|
||||
c.normal_impulse = new_impulse
|
||||
delta = new_impulse - old_impulse
|
||||
impulse_x = delta * c.normal_x
|
||||
impulse_y = delta * c.normal_y
|
||||
velocity_x[a] -= impulse_x * c.inv_mass_a
|
||||
velocity_y[a] -= impulse_y * c.inv_mass_a
|
||||
velocity_x[b] += impulse_x * c.inv_mass_b
|
||||
velocity_y[b] += impulse_y * c.inv_mass_b
|
||||
|
||||
# --- Reibung: v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), ω × r = (−ω·r.y, ω·r.x)
|
||||
rel_x = (velocity_x[b] - angular[b] * c.offset_b_y) - (
|
||||
velocity_x[a] - angular[a] * c.offset_a_y
|
||||
)
|
||||
rel_y = (velocity_y[b] + angular[b] * c.offset_b_x) - (
|
||||
velocity_y[a] + angular[a] * c.offset_a_x
|
||||
)
|
||||
tangent_speed = rel_x * c.tangent_x + rel_y * c.tangent_y
|
||||
# λ so, dass das Gleiten verschwindet; danach Coulomb-Grenze auf den Gesamtimpuls.
|
||||
max_friction = c.friction * c.normal_impulse
|
||||
old_impulse = c.tangent_impulse
|
||||
new_impulse = old_impulse - tangent_speed * c.tangent_mass
|
||||
if new_impulse > max_friction:
|
||||
new_impulse = max_friction
|
||||
elif new_impulse < -max_friction:
|
||||
new_impulse = -max_friction
|
||||
c.tangent_impulse = new_impulse
|
||||
delta = new_impulse - old_impulse
|
||||
impulse_x = delta * c.tangent_x
|
||||
impulse_y = delta * c.tangent_y
|
||||
velocity_x[a] -= impulse_x * c.inv_mass_a
|
||||
velocity_y[a] -= impulse_y * c.inv_mass_a
|
||||
angular[a] -= c.inv_inertia_a * (c.offset_a_x * impulse_y - c.offset_a_y * impulse_x)
|
||||
velocity_x[b] += impulse_x * c.inv_mass_b
|
||||
velocity_y[b] += impulse_y * c.inv_mass_b
|
||||
angular[b] += c.inv_inertia_b * (c.offset_b_x * impulse_y - c.offset_b_y * impulse_x)
|
||||
|
||||
# --- Rollwiderstand: bremst die relative Drehung, höchstens μ_r·P_n·r.
|
||||
if c.rolling_mass > 0.0:
|
||||
max_rolling = c.rolling_limit * c.normal_impulse
|
||||
old_impulse = c.rolling_impulse
|
||||
new_impulse = old_impulse - (angular[b] - angular[a]) * c.rolling_mass
|
||||
if new_impulse > max_rolling:
|
||||
new_impulse = max_rolling
|
||||
elif new_impulse < -max_rolling:
|
||||
new_impulse = -max_rolling
|
||||
c.rolling_impulse = new_impulse
|
||||
delta = new_impulse - old_impulse
|
||||
angular[a] -= c.inv_inertia_a * delta
|
||||
angular[b] += c.inv_inertia_b * delta
|
||||
|
||||
def _store_velocities(self) -> None:
|
||||
"""Überträgt die Ergebnisse auf Körper und Kontakte und merkt sich die Impulse."""
|
||||
for index, body in enumerate(self._bodies):
|
||||
if body.is_static:
|
||||
continue
|
||||
body.velocity = Vector2(self._velocity_x[index], self._velocity_y[index])
|
||||
body.angular_velocity = self._angular_velocity[index]
|
||||
|
||||
cache: dict[tuple[int, int], _CachedImpulses] = {}
|
||||
for c in self._constraints:
|
||||
contact = c.contact
|
||||
contact.normal_impulse = c.normal_impulse
|
||||
contact.tangent_impulse = c.tangent_impulse
|
||||
cache[contact.key] = (
|
||||
c.normal_impulse,
|
||||
c.tangent_impulse,
|
||||
c.rolling_impulse,
|
||||
c.normal_x,
|
||||
c.normal_y,
|
||||
)
|
||||
self._impulse_cache = cache
|
||||
|
||||
# ------------------------------------------------------------------ Positionen
|
||||
|
||||
def correct_positions(self) -> None:
|
||||
"""Baut verbliebene Durchdringungen der zuletzt gelösten Kontakte ab.
|
||||
|
||||
Warum überhaupt? Impulse korrigieren nur Geschwindigkeiten. Durch den diskreten
|
||||
Zeitschritt überlappen Körper trotzdem ein wenig (ein fallender Körper "landet"
|
||||
meist schon etwas in der Unterlage), und ohne Korrektur würden Stapel langsam
|
||||
ineinander einsinken.
|
||||
|
||||
Warum über Positionen statt über Zusatzgeschwindigkeit? Eine Geschwindigkeit, die
|
||||
Körper auseinandertreibt, bleibt nach der Trennung erhalten und schleudert sie
|
||||
davon – besonders bei großen Überlappungen, etwa wenn ein Objekt mitten in einen
|
||||
Haufen gesetzt wird. Die direkte Verschiebung ("Projektion") erzeugt dagegen keine
|
||||
Bewegungsenergie.
|
||||
|
||||
Verschoben wird im Verhältnis der inversen Massen – der leichtere Körper weicht
|
||||
weiter aus, eine Wand gar nicht. Die kleine Toleranz ``penetration_slop`` bleibt
|
||||
stehen, damit der Kontakt im nächsten Schritt sicher wieder erkannt wird.
|
||||
"""
|
||||
constraints = self._constraints
|
||||
if not constraints:
|
||||
return
|
||||
config = self._config
|
||||
slop = config.penetration_slop
|
||||
factor = config.position_correction_factor
|
||||
max_correction = config.max_position_correction
|
||||
bodies = self._bodies
|
||||
position_x = [body.position.x for body in bodies]
|
||||
position_y = [body.position.y for body in bodies]
|
||||
start_x = self._start_x
|
||||
start_y = self._start_y
|
||||
|
||||
for _ in range(config.position_iterations):
|
||||
for c in constraints:
|
||||
a = c.index_a
|
||||
b = c.index_b
|
||||
# Aktuelle Tiefe = Tiefe bei der Erkennung minus die seither erfolgte
|
||||
# Relativbewegung entlang der Normalen. Für Wände und Rechtecke ist das exakt
|
||||
# (keine Drehung), für Kreise eine sehr gute Näherung – und viel billiger als
|
||||
# eine erneute Kollisionserkennung.
|
||||
moved_x = (position_x[b] - start_x[b]) - (position_x[a] - start_x[a])
|
||||
moved_y = (position_y[b] - start_y[b]) - (position_y[a] - start_y[a])
|
||||
penetration = c.penetration - (moved_x * c.normal_x + moved_y * c.normal_y)
|
||||
correction = (penetration - slop) * factor
|
||||
if correction <= 0.0:
|
||||
continue
|
||||
correction = min(correction, max_correction)
|
||||
# Aufteilen im Verhältnis der inversen Massen (Summe > 0, da nie zwei
|
||||
# statische Körper einen Kontakt bilden).
|
||||
share = correction / (c.inv_mass_a + c.inv_mass_b)
|
||||
shift_a = share * c.inv_mass_a
|
||||
shift_b = share * c.inv_mass_b
|
||||
position_x[a] -= c.normal_x * shift_a
|
||||
position_y[a] -= c.normal_y * shift_a
|
||||
position_x[b] += c.normal_x * shift_b
|
||||
position_y[b] += c.normal_y * shift_b
|
||||
|
||||
for index, body in enumerate(bodies):
|
||||
if body.is_static:
|
||||
continue
|
||||
new_x = position_x[index]
|
||||
new_y = position_y[index]
|
||||
if new_x != body.position.x or new_y != body.position.y:
|
||||
body.position = Vector2(new_x, new_y)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Fester Zeitschritt, entkoppelt von der Bildrate ("Fixed Timestep").
|
||||
|
||||
Warum: Die Stabilität der Integration hängt vom Zeitschritt ab. Würde die Physik einfach
|
||||
die (schwankende) Frame-Dauer als dt verwenden, verhielte sich die Simulation auf
|
||||
schnellen und langsamen Rechnern unterschiedlich, und ein einzelner Ruckler (z. B. 200 ms
|
||||
beim Verschieben des Fensters) ließe Körper durcheinander hindurchspringen.
|
||||
|
||||
Stattdessen sammelt ein Akkumulator die real vergangene Zeit und gibt sie in festen
|
||||
Portionen ``dt`` an die Physik weiter. Der Rest bleibt für den nächsten Frame liegen; sein
|
||||
Anteil an dt (``alpha``) erlaubt es, beim Zeichnen zwischen den letzten beiden
|
||||
Physikzuständen zu interpolieren, damit die Bewegung trotzdem flüssig aussieht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from sandbox.config import NUMERIC_EPSILON
|
||||
|
||||
|
||||
class FixedTimestep:
|
||||
"""Akkumulator, der Frame-Zeiten in eine Anzahl fester Physikschritte übersetzt.
|
||||
|
||||
Args:
|
||||
step: Fester Zeitschritt in s.
|
||||
max_steps_per_frame: Obergrenze der Schritte pro Frame. Schafft der Rechner die
|
||||
Physik nicht in Echtzeit, würde der Rückstand sonst immer weiter wachsen
|
||||
("Spiral of Death"). Mit Obergrenze läuft die Simulation dann nur langsamer.
|
||||
"""
|
||||
|
||||
def __init__(self, step: float, max_steps_per_frame: int) -> None:
|
||||
if not step > 0.0:
|
||||
raise ValueError(f"Der Zeitschritt muss positiv sein: {step}")
|
||||
if max_steps_per_frame < 1:
|
||||
raise ValueError("Es muss mindestens ein Schritt pro Frame erlaubt sein")
|
||||
self.step = step
|
||||
self.max_steps_per_frame = max_steps_per_frame
|
||||
self._accumulator = 0.0
|
||||
|
||||
@property
|
||||
def alpha(self) -> float:
|
||||
"""Anteil des angebrochenen Schritts in [0, 1) – Gewicht für die Interpolation."""
|
||||
return self._accumulator / self.step
|
||||
|
||||
def advance(self, frame_time: float) -> int:
|
||||
"""Nimmt die Dauer eines Frames entgegen und liefert die Zahl fälliger Schritte.
|
||||
|
||||
Args:
|
||||
frame_time: Real vergangene Zeit seit dem letzten Aufruf in s.
|
||||
|
||||
Returns:
|
||||
Anzahl der jetzt auszuführenden Physikschritte.
|
||||
"""
|
||||
if frame_time < 0.0:
|
||||
raise ValueError(f"Die Frame-Zeit darf nicht negativ sein: {frame_time}")
|
||||
self._accumulator += frame_time
|
||||
# Das kleine Epsilon fängt Rundungsfehler ab: 2·(1/120) ergibt als Gleitkommazahl
|
||||
# nicht exakt 1/60, und ohne Toleranz ginge gelegentlich ein Schritt "verloren".
|
||||
steps = math.floor(self._accumulator / self.step + NUMERIC_EPSILON)
|
||||
if steps > self.max_steps_per_frame:
|
||||
steps = self.max_steps_per_frame
|
||||
self._accumulator = 0.0 # Rückstand verwerfen statt ihn endlos mitzuschleppen.
|
||||
else:
|
||||
self._accumulator = max(0.0, self._accumulator - steps * self.step)
|
||||
return steps
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Verwirft angesammelte Zeit (z. B. beim Pausieren)."""
|
||||
self._accumulator = 0.0
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Eigene 2D-Vektorklasse der Physik-Schicht.
|
||||
|
||||
Die Physik nutzt bewusst weder ``pygame.Vector2`` noch externe Mathematik-Bibliotheken,
|
||||
sondern nur diese Klasse und das Standardmodul :mod:`math`. Dadurch hängt die Physik nicht
|
||||
von einer Grafikbibliothek ab und lässt sich isoliert testen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Final
|
||||
|
||||
from sandbox.config import NUMERIC_EPSILON
|
||||
|
||||
# ``object.__setattr__`` einmal nachschlagen statt bei jedem Konstruktoraufruf: Vektoren
|
||||
# entstehen tausendfach pro Simulationsschritt, und das normale Setzen ist gesperrt (s. u.).
|
||||
_set_slot = object.__setattr__
|
||||
|
||||
|
||||
class Vector2:
|
||||
"""Unveränderlicher zweidimensionaler Vektor.
|
||||
|
||||
Jede Operation liefert einen neuen Vektor, kein Operand wird verändert. Vektoren
|
||||
verhalten sich damit wie Zahlen: Ein Körper kann seine Position herausgeben, ohne dass
|
||||
jemand sie über die geteilte Referenz versehentlich mitverändert. Zuweisungen wie
|
||||
``v.x = 1.0`` lösen deshalb einen :class:`AttributeError` aus.
|
||||
|
||||
Attributes:
|
||||
x: x-Komponente.
|
||||
y: y-Komponente (zeigt wie auf dem Bildschirm nach unten).
|
||||
"""
|
||||
|
||||
__slots__ = ("x", "y")
|
||||
|
||||
x: float
|
||||
y: float
|
||||
|
||||
def __init__(self, x: float = 0.0, y: float = 0.0) -> None:
|
||||
_set_slot(self, "x", x)
|
||||
_set_slot(self, "y", y)
|
||||
|
||||
# ------------------------------------------------------------------ Unveränderlichkeit
|
||||
|
||||
def __setattr__(self, name: str, value: object) -> None:
|
||||
raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gesetzt werden)")
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gelöscht werden)")
|
||||
|
||||
def __reduce__(self) -> tuple[type[Vector2], tuple[float, float]]:
|
||||
# copy und pickle würden die Slots sonst per setattr befüllen – das ist gesperrt.
|
||||
return (Vector2, (self.x, self.y))
|
||||
|
||||
# ------------------------------------------------------------------ Arithmetik
|
||||
|
||||
def __add__(self, other: Vector2) -> Vector2:
|
||||
return Vector2(self.x + other.x, self.y + other.y)
|
||||
|
||||
def __sub__(self, other: Vector2) -> Vector2:
|
||||
return Vector2(self.x - other.x, self.y - other.y)
|
||||
|
||||
def __mul__(self, scalar: float) -> Vector2:
|
||||
return Vector2(self.x * scalar, self.y * scalar)
|
||||
|
||||
def __rmul__(self, scalar: float) -> Vector2:
|
||||
return Vector2(self.x * scalar, self.y * scalar)
|
||||
|
||||
def __truediv__(self, scalar: float) -> Vector2:
|
||||
return Vector2(self.x / scalar, self.y / scalar)
|
||||
|
||||
def __neg__(self) -> Vector2:
|
||||
return Vector2(-self.x, -self.y)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Vector2):
|
||||
return NotImplemented
|
||||
return self.x == other.x and self.y == other.y
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.x, self.y))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Vector2({self.x!r}, {self.y!r})"
|
||||
|
||||
# ------------------------------------------------------------------ Vektoroperationen
|
||||
|
||||
def dot(self, other: Vector2) -> float:
|
||||
"""Skalarprodukt ``a·b = a.x·b.x + a.y·b.y``.
|
||||
|
||||
Liefert die Länge der Projektion von ``other`` auf diesen Vektor (mal dessen Länge).
|
||||
Mit einer Einheitsnormalen n misst ``v.dot(n)`` also, wie schnell sich etwas
|
||||
entlang n bewegt.
|
||||
"""
|
||||
return self.x * other.x + self.y * other.y
|
||||
|
||||
def cross(self, other: Vector2) -> float:
|
||||
"""z-Komponente des Kreuzprodukts ``a × b = a.x·b.y − a.y·b.x``.
|
||||
|
||||
In 2D ist das Kreuzprodukt ein Skalar. Physikalisch ist ``r × F`` das Drehmoment,
|
||||
das eine Kraft (bzw. ein Impuls) F am Hebelarm r erzeugt.
|
||||
"""
|
||||
return self.x * other.y - self.y * other.x
|
||||
|
||||
def length(self) -> float:
|
||||
"""Euklidische Länge (Betrag) des Vektors."""
|
||||
return math.hypot(self.x, self.y)
|
||||
|
||||
def length_squared(self) -> float:
|
||||
"""Quadrat der Länge – spart die Wurzel, wenn nur Längen verglichen werden."""
|
||||
return self.x * self.x + self.y * self.y
|
||||
|
||||
def normalized(self) -> Vector2:
|
||||
"""Einheitsvektor gleicher Richtung.
|
||||
|
||||
Der Nullvektor hat keine Richtung. Statt eine Division durch 0 (und damit NaN-Werte,
|
||||
die sich durch die ganze Simulation fressen würden) zu riskieren, wird dann der
|
||||
Nullvektor zurückgegeben; der Aufrufer entscheidet über eine Ersatzrichtung.
|
||||
"""
|
||||
length = math.hypot(self.x, self.y)
|
||||
if length <= NUMERIC_EPSILON:
|
||||
return Vector2(0.0, 0.0)
|
||||
return Vector2(self.x / length, self.y / length)
|
||||
|
||||
def perpendicular(self) -> Vector2:
|
||||
"""Um +90° gedrehter Vektor ``(−y, x)``.
|
||||
|
||||
Nützlich für Tangenten und für ``ω × r``: Die Bahngeschwindigkeit eines Punktes im
|
||||
Abstand r eines mit ω drehenden Körpers ist ``ω · r.perpendicular()``.
|
||||
"""
|
||||
return Vector2(-self.y, self.x)
|
||||
|
||||
def distance_to(self, other: Vector2) -> float:
|
||||
"""Abstand zwischen zwei Punkten."""
|
||||
return math.hypot(other.x - self.x, other.y - self.y)
|
||||
|
||||
def clamped(self, max_length: float) -> Vector2:
|
||||
"""Vektor gleicher Richtung, dessen Länge höchstens ``max_length`` beträgt."""
|
||||
length_sq = self.x * self.x + self.y * self.y
|
||||
if length_sq <= max_length * max_length:
|
||||
return self
|
||||
scale = max_length / math.sqrt(length_sq)
|
||||
return Vector2(self.x * scale, self.y * scale)
|
||||
|
||||
def lerp(self, other: Vector2, t: float) -> Vector2:
|
||||
"""Lineare Interpolation: ``t = 0`` liefert ``self``, ``t = 1`` liefert ``other``."""
|
||||
return Vector2(self.x + (other.x - self.x) * t, self.y + (other.y - self.y) * t)
|
||||
|
||||
def is_finite(self) -> bool:
|
||||
"""True, wenn keine Komponente NaN oder unendlich ist."""
|
||||
return math.isfinite(self.x) and math.isfinite(self.y)
|
||||
|
||||
def is_close(self, other: Vector2, tolerance: float = NUMERIC_EPSILON) -> bool:
|
||||
"""Komponentenweiser Vergleich mit absoluter Toleranz."""
|
||||
return abs(self.x - other.x) <= tolerance and abs(self.y - other.y) <= tolerance
|
||||
|
||||
def to_tuple(self) -> tuple[float, float]:
|
||||
"""Komponenten als Tupel ``(x, y)``, z. B. für Zeichenfunktionen."""
|
||||
return (self.x, self.y)
|
||||
|
||||
|
||||
#: Der Nullvektor. Da Vektoren unveränderlich sind, darf er überall geteilt werden.
|
||||
ZERO: Final = Vector2(0.0, 0.0)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Die Physikwelt: verwaltet Körper und Wände und führt Simulationsschritte aus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from sandbox.config import PhysicsConfig
|
||||
from sandbox.physics.body import Body, Material
|
||||
from sandbox.physics.broadphase import find_candidate_pairs
|
||||
from sandbox.physics.collision import Contact, collide
|
||||
from sandbox.physics.sleep import update_sleep, wake_touched_bodies
|
||||
from sandbox.physics.solver import ContactSolver
|
||||
from sandbox.physics.vector import ZERO, Vector2
|
||||
|
||||
|
||||
class World:
|
||||
"""Rechteckige Welt mit Wänden an allen vier Seiten.
|
||||
|
||||
Koordinaten: Ursprung oben links, x nach rechts, y nach unten, Einheit px. Die Welt
|
||||
kennt keinen Bildschirm – wo sie gezeichnet wird, entscheidet die Anwendung.
|
||||
|
||||
Ein Aufruf von :meth:`step` rückt die Simulation um einen festen Zeitschritt vor
|
||||
(semi-impliziter Euler mit Kontaktauflösung, Details in :meth:`step`).
|
||||
|
||||
Args:
|
||||
width: Breite der Welt in px.
|
||||
height: Höhe der Welt in px.
|
||||
config: Physikparameter.
|
||||
|
||||
Attributes:
|
||||
width: Breite der Welt in px.
|
||||
height: Höhe der Welt in px.
|
||||
config: Physikparameter.
|
||||
bodies: Alle dynamischen Körper in Einfügereihenfolge (später eingefügte liegen beim
|
||||
Zeichnen oben).
|
||||
walls: Die vier statischen Wände (links, rechts, oben, unten).
|
||||
contacts: Die Kontakte des letzten Schritts (für die Debug-Anzeige).
|
||||
step_count: Anzahl bisher ausgeführter Schritte.
|
||||
"""
|
||||
|
||||
def __init__(self, width: float, height: float, config: PhysicsConfig) -> None:
|
||||
if not (width > 0.0 and height > 0.0):
|
||||
raise ValueError(f"Die Welt braucht eine positive Größe: {width} x {height}")
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.config = config
|
||||
self.bodies: list[Body] = []
|
||||
self.contacts: list[Contact] = []
|
||||
self.step_count = 0
|
||||
self._gravity_enabled = True
|
||||
self._solver = ContactSolver(config)
|
||||
|
||||
wall_material = Material(
|
||||
density=0.0, restitution=config.wall_restitution, friction=config.wall_friction
|
||||
)
|
||||
# Jede Wandnormale zeigt in die Welt hinein; frei ist die Seite normal·p ≥ offset.
|
||||
self.walls: list[Body] = [
|
||||
Body.wall(Vector2(1.0, 0.0), 0.0, wall_material), # links: x ≥ 0
|
||||
Body.wall(Vector2(-1.0, 0.0), -width, wall_material), # rechts: x ≤ width
|
||||
Body.wall(Vector2(0.0, 1.0), 0.0, wall_material), # oben: y ≥ 0
|
||||
Body.wall(Vector2(0.0, -1.0), -height, wall_material), # unten: y ≤ height
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------ Schwerkraft
|
||||
|
||||
@property
|
||||
def gravity_enabled(self) -> bool:
|
||||
"""Ob die Schwerkraft wirkt."""
|
||||
return self._gravity_enabled
|
||||
|
||||
@gravity_enabled.setter
|
||||
def gravity_enabled(self, enabled: bool) -> None:
|
||||
if enabled != self._gravity_enabled:
|
||||
self._gravity_enabled = enabled
|
||||
# Schlafende Körper würden die Änderung sonst nicht bemerken und z. B. ohne
|
||||
# Schwerkraft am Boden kleben bleiben bzw. mit Schwerkraft in der Luft hängen.
|
||||
self.wake_all()
|
||||
|
||||
@property
|
||||
def gravity(self) -> Vector2:
|
||||
"""Aktuell wirkende Schwerkraftbeschleunigung (Nullvektor, wenn abgeschaltet)."""
|
||||
if not self._gravity_enabled:
|
||||
return ZERO
|
||||
return Vector2(*self.config.gravity)
|
||||
|
||||
# ------------------------------------------------------------------ Körperverwaltung
|
||||
|
||||
def add_body(self, body: Body) -> Body:
|
||||
"""Fügt einen dynamischen Körper hinzu und gibt ihn zurück."""
|
||||
if body.is_static:
|
||||
raise ValueError("Statische Körper werden von der Welt selbst verwaltet (Wände)")
|
||||
self.bodies.append(body)
|
||||
return body
|
||||
|
||||
def remove_body(self, body: Body) -> None:
|
||||
"""Entfernt einen Körper und weckt alles, was auf ihm gelegen haben könnte.
|
||||
|
||||
Dafür genügt es, seine Schlaf-Insel zu wecken: Schlafende Körper, die einander
|
||||
berühren, sind immer gemeinsam eingeschlafen und gehören damit zur selben Insel.
|
||||
"""
|
||||
self.bodies.remove(body)
|
||||
body.wake()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Entfernt alle Körper."""
|
||||
self.bodies.clear()
|
||||
self.contacts = []
|
||||
self._solver.reset()
|
||||
|
||||
def wake_all(self) -> None:
|
||||
"""Weckt alle Körper auf."""
|
||||
for body in self.bodies:
|
||||
body.wake()
|
||||
|
||||
def body_at(self, point: Vector2, tolerance: float = 0.0) -> Body | None:
|
||||
"""Oberster Körper an einem Punkt (später eingefügte Körper liegen oben).
|
||||
|
||||
Args:
|
||||
point: Punkt in Weltkoordinaten.
|
||||
tolerance: Vergrößert die Körper beim Test, damit kleine Objekte leichter zu
|
||||
treffen sind.
|
||||
"""
|
||||
for body in reversed(self.bodies):
|
||||
if body.contains_point(point, tolerance):
|
||||
return body
|
||||
return None
|
||||
|
||||
def clamp_inside(self, position: Vector2, half_extents: tuple[float, float]) -> Vector2:
|
||||
"""Verschiebt eine Mittelpunktsposition so, dass ein Körper der Größe ganz hineinpasst."""
|
||||
half_x, half_y = half_extents
|
||||
x = min(max(position.x, half_x), self.width - half_x)
|
||||
y = min(max(position.y, half_y), self.height - half_y)
|
||||
if x == position.x and y == position.y:
|
||||
return position
|
||||
return Vector2(x, y)
|
||||
|
||||
# ------------------------------------------------------------------ Simulation
|
||||
|
||||
def step(self, dt: float) -> None:
|
||||
"""Rückt die Simulation um ``dt`` Sekunden vor.
|
||||
|
||||
Ablauf (semi-impliziter Euler mit Kontaktauflösung):
|
||||
|
||||
1. Zustand merken: Position und Winkel für die Zeichen-Interpolation, die
|
||||
Geschwindigkeit für die Aufprallgeschwindigkeit beim Abprallen.
|
||||
2. Geschwindigkeiten integrieren: ``v += g·dt`` (plus Dämpfung).
|
||||
3. Kontakte suchen: Sweep-and-Prune, dann genaue Tests; schlafende Körper, die
|
||||
berührt werden, samt ihrer Schlaf-Insel wecken; danach Wandkontakte der wachen
|
||||
Körper.
|
||||
4. Impulse lösen (Restitution, Reibung, Rollwiderstand) und Geschwindigkeiten
|
||||
begrenzen.
|
||||
5. Positionen integrieren: ``x += v·dt`` – mit der *neuen* Geschwindigkeit. Genau
|
||||
das macht den Euler "semi-implizit" (symplektisch): Anders als beim expliziten
|
||||
Euler wächst die Energie dabei nicht systematisch an, Umlaufbahnen und
|
||||
Schwingungen bleiben stabil.
|
||||
6. Verbliebene Durchdringungen über die Positionen abbauen.
|
||||
7. Notbremse: kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand.
|
||||
8. Schlafmodus aktualisieren.
|
||||
"""
|
||||
awake = [body for body in self.bodies if not body.sleeping]
|
||||
self._remember_state(awake)
|
||||
|
||||
self._integrate_velocities(awake, dt)
|
||||
|
||||
contacts = self._find_body_contacts()
|
||||
woken = wake_touched_bodies(contacts)
|
||||
self._remember_state(woken)
|
||||
awake.extend(woken)
|
||||
contacts.extend(self._find_wall_contacts(awake))
|
||||
|
||||
self._solver.solve_velocities(contacts)
|
||||
self._clamp_velocities(awake)
|
||||
self._integrate_positions(awake, dt)
|
||||
self._solver.correct_positions()
|
||||
self._enforce_bounds(awake)
|
||||
|
||||
if self.config.sleep_enabled:
|
||||
update_sleep(awake, contacts, dt, self.config)
|
||||
self.contacts = contacts
|
||||
self.step_count += 1
|
||||
|
||||
@staticmethod
|
||||
def _remember_state(bodies: list[Body]) -> None:
|
||||
"""Merkt sich den Zustand zu Beginn des Schritts (siehe ``Body.previous_*``)."""
|
||||
for body in bodies:
|
||||
body.previous_position = body.position
|
||||
body.previous_angle = body.angle
|
||||
body.previous_velocity = body.velocity
|
||||
|
||||
def _integrate_velocities(self, bodies: list[Body], dt: float) -> None:
|
||||
"""Wendet Schwerkraft und Dämpfung auf die Geschwindigkeiten an."""
|
||||
gravity = self.gravity
|
||||
gravity_dx = gravity.x * dt
|
||||
gravity_dy = gravity.y * dt
|
||||
# Dämpfung als v ← v / (1 + c·dt): Näherung von v·e^(−c·dt), die anders als
|
||||
# v·(1 − c·dt) auch bei großem c·dt nie das Vorzeichen umkehrt.
|
||||
linear_factor = 1.0 / (1.0 + dt * self.config.linear_damping)
|
||||
angular_factor = 1.0 / (1.0 + dt * self.config.angular_damping)
|
||||
for body in bodies:
|
||||
if body.held:
|
||||
continue # Die Geschwindigkeit gehaltener Körper gibt die Maus vor.
|
||||
velocity = body.velocity
|
||||
body.velocity = Vector2(
|
||||
(velocity.x + gravity_dx) * linear_factor,
|
||||
(velocity.y + gravity_dy) * linear_factor,
|
||||
)
|
||||
body.angular_velocity *= angular_factor
|
||||
|
||||
def _find_body_contacts(self) -> list[Contact]:
|
||||
"""Kontakte zwischen dynamischen Körpern (grobe Vorauswahl, dann genauer Test)."""
|
||||
contacts: list[Contact] = []
|
||||
for body_a, body_b in find_candidate_pairs(self.bodies):
|
||||
contact = collide(body_a, body_b)
|
||||
if contact is not None:
|
||||
contacts.append(contact)
|
||||
return contacts
|
||||
|
||||
def _find_wall_contacts(self, bodies: list[Body]) -> list[Contact]:
|
||||
"""Kontakte der wachen Körper mit den Wänden."""
|
||||
left, right, top, bottom = self.walls
|
||||
width = self.width
|
||||
height = self.height
|
||||
contacts: list[Contact] = []
|
||||
for body in bodies:
|
||||
min_x, min_y, max_x, max_y = body.bounds()
|
||||
# Billiger Vortest über die umschließende Box, genauer Test nur bei Bedarf.
|
||||
for touches, wall in (
|
||||
(min_x <= 0.0, left),
|
||||
(max_x >= width, right),
|
||||
(min_y <= 0.0, top),
|
||||
(max_y >= height, bottom),
|
||||
):
|
||||
if touches:
|
||||
contact = collide(body, wall)
|
||||
if contact is not None:
|
||||
contacts.append(contact)
|
||||
return contacts
|
||||
|
||||
def _clamp_velocities(self, bodies: list[Body]) -> None:
|
||||
"""Begrenzt Geschwindigkeit und Drehung (Schutz vor Tunneln und Explosionen)."""
|
||||
max_speed = self.config.max_speed
|
||||
max_speed_sq = max_speed * max_speed
|
||||
max_angular = self.config.max_angular_speed
|
||||
for body in bodies:
|
||||
velocity = body.velocity
|
||||
speed_sq = velocity.length_squared()
|
||||
if speed_sq > max_speed_sq:
|
||||
body.velocity = velocity * (max_speed / math.sqrt(speed_sq))
|
||||
if abs(body.angular_velocity) > max_angular:
|
||||
body.angular_velocity = math.copysign(max_angular, body.angular_velocity)
|
||||
|
||||
def _integrate_positions(self, bodies: list[Body], dt: float) -> None:
|
||||
"""``x += v·dt`` und ``θ += ω·dt`` mit den bereits aktualisierten Geschwindigkeiten."""
|
||||
full_turn = math.tau
|
||||
for body in bodies:
|
||||
velocity = body.velocity
|
||||
position = body.position
|
||||
body.position = Vector2(position.x + velocity.x * dt, position.y + velocity.y * dt)
|
||||
if body.angular_velocity != 0.0:
|
||||
# Winkel im Bereich [−π, π] halten, damit er über lange Zeit nicht an
|
||||
# Genauigkeit verliert.
|
||||
body.angle = math.remainder(body.angle + body.angular_velocity * dt, full_turn)
|
||||
|
||||
def _enforce_bounds(self, bodies: list[Body]) -> None:
|
||||
"""Notbremse: Kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand.
|
||||
|
||||
Im Normalfall halten die Wandkontakte die Körper in der Welt. Die Grenze liegt über
|
||||
der größten Eindringtiefe eines frei fliegenden Körpers pro Schritt und greift daher
|
||||
nur in Extremfällen, etwa wenn ein Körper von einem gezogenen Objekt gegen die Wand
|
||||
gequetscht wird. Geschwindigkeiten bleiben unangetastet: Den eigentlichen Stoß mit
|
||||
der Wand löst im nächsten Schritt der Solver.
|
||||
"""
|
||||
allowed = self.config.max_wall_penetration
|
||||
for body in bodies:
|
||||
half_x, half_y = body.half_extents()
|
||||
position = body.position
|
||||
x = min(max(position.x, half_x - allowed), self.width - half_x + allowed)
|
||||
y = min(max(position.y, half_y - allowed), self.height - half_y + allowed)
|
||||
if x != position.x or y != position.y:
|
||||
body.position = Vector2(x, y)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Zeichnet die Physikwelt: Hintergrund, Körper, Hervorhebungen, Vorschau und Debug-Ansicht."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import Color
|
||||
from sandbox.interaction import BodyStyle, ShapeKind
|
||||
from sandbox.physics import ZERO, Body, Circle, Vector2, World
|
||||
from sandbox.ui.drawing import draw_box, draw_circle
|
||||
from sandbox.ui.style import UiStyle, shade
|
||||
from sandbox.viewport import Viewport
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpawnPreview:
|
||||
"""Halbtransparente Vorschau des Objekts, das ein Klick erzeugen würde.
|
||||
|
||||
Attributes:
|
||||
shape: Form des Objekts.
|
||||
half_extents: Halbe Breite und Höhe in px.
|
||||
color: Farbe des Objekts.
|
||||
screen_center: Mittelpunkt auf dem Bildschirm (kann über der Sidebar liegen,
|
||||
wenn ein Objekt gerade aus der Vorschau-Kachel gezogen wird).
|
||||
"""
|
||||
|
||||
shape: ShapeKind
|
||||
half_extents: tuple[float, float]
|
||||
color: Color
|
||||
screen_center: tuple[float, float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorldOverlay:
|
||||
"""Zustand der Bedienung, der über der Welt angezeigt wird.
|
||||
|
||||
Attributes:
|
||||
hovered: Körper unter der Maus.
|
||||
held: Gezogener Körper.
|
||||
throw_velocity: Aktuelle Wurfgeschwindigkeit des gezogenen Körpers.
|
||||
paused: Ob die Simulation pausiert ist.
|
||||
debug: Ob die Debug-Ansicht (Kontakte, Schlafzustand) aktiv ist.
|
||||
debug_lines: Textzeilen der Debug-Ansicht.
|
||||
"""
|
||||
|
||||
hovered: Body | None = None
|
||||
held: Body | None = None
|
||||
throw_velocity: Vector2 = ZERO
|
||||
paused: bool = False
|
||||
debug: bool = False
|
||||
debug_lines: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def interpolate_angle(previous: float, current: float, alpha: float) -> float:
|
||||
"""Interpoliert einen Winkel auf dem kürzeren Weg.
|
||||
|
||||
Der Winkel wird in der Physik auf [−π, π] gehalten. Springt er von +3,1 auf −3,1, hat
|
||||
sich der Körper nur um 0,08 rad gedreht – naive Interpolation würde ihn für einen Frame
|
||||
fast eine ganze Umdrehung zurückdrehen lassen.
|
||||
"""
|
||||
return previous + math.remainder(current - previous, math.tau) * alpha
|
||||
|
||||
|
||||
class Renderer:
|
||||
"""Zeichnet die Welt in ihren Bildschirmbereich.
|
||||
|
||||
Args:
|
||||
screen: Zielfläche (das Fenster).
|
||||
viewport: Lage der Welt auf dem Bildschirm.
|
||||
style: Farben, Schriften, Maße.
|
||||
"""
|
||||
|
||||
def __init__(self, screen: pygame.Surface, viewport: Viewport, style: UiStyle) -> None:
|
||||
self.screen = screen
|
||||
self.viewport = viewport
|
||||
self.style = style
|
||||
self._background = self._render_background()
|
||||
|
||||
def _render_background(self) -> pygame.Surface:
|
||||
"""Hintergrund mit Hilfsgitter – einmal vorberechnet statt jedes Bild neu."""
|
||||
theme = self.style.theme
|
||||
spacing = self.style.ui.grid_spacing
|
||||
width, height = self.viewport.width, self.viewport.height
|
||||
surface = pygame.Surface((width, height))
|
||||
surface.fill(theme.world_background)
|
||||
for x in range(spacing, width, spacing):
|
||||
pygame.draw.line(surface, theme.grid, (x, 0), (x, height))
|
||||
for y in range(spacing, height, spacing):
|
||||
pygame.draw.line(surface, theme.grid, (0, y), (width, y))
|
||||
return surface
|
||||
|
||||
# ------------------------------------------------------------------ Welt
|
||||
|
||||
def draw_world(self, world: World, alpha: float, overlay: WorldOverlay) -> None:
|
||||
"""Zeichnet Hintergrund, alle Körper und die Bedien-Hinweise.
|
||||
|
||||
Args:
|
||||
world: Die Welt.
|
||||
alpha: Interpolationsgewicht zwischen letztem und aktuellem Physikzustand.
|
||||
overlay: Hervorhebungen und Zusatzanzeigen.
|
||||
"""
|
||||
screen = self.screen
|
||||
area = pygame.Rect(self.viewport.rect)
|
||||
screen.blit(self._background, area.topleft)
|
||||
screen.set_clip(area) # Körper, die leicht in eine Wand ragen, nicht über die Sidebar
|
||||
for body in world.bodies:
|
||||
self._draw_body(body, alpha, overlay)
|
||||
if overlay.held is not None:
|
||||
self._draw_throw_arrow(overlay.held, overlay.throw_velocity)
|
||||
if overlay.debug:
|
||||
self._draw_debug(world, overlay.debug_lines)
|
||||
screen.set_clip(None)
|
||||
pygame.draw.rect(screen, self.style.theme.world_border, area, self.style.ui.border_width)
|
||||
if overlay.paused:
|
||||
self._draw_pause_badge()
|
||||
|
||||
def _draw_body(self, body: Body, alpha: float, overlay: WorldOverlay) -> None:
|
||||
theme = self.style.theme
|
||||
ui = self.style.ui
|
||||
# Zwischen zwei Physikschritten interpolieren: Die Physik läuft mit 120 Hz, der
|
||||
# Bildschirm z. B. mit 60 Hz – ohne Interpolation ruckelt die Bewegung sichtbar.
|
||||
position = body.previous_position.lerp(body.position, alpha)
|
||||
center = self.viewport.to_screen(position)
|
||||
style = body.user_data
|
||||
color = style.color if isinstance(style, BodyStyle) else theme.body_palette[0]
|
||||
outline = shade(color, theme.outline_shade)
|
||||
if body.sleeping and overlay.debug:
|
||||
outline = theme.debug_sleeping
|
||||
|
||||
if body is overlay.held:
|
||||
highlight: Color | None = theme.held_outline
|
||||
elif body is overlay.hovered:
|
||||
highlight = theme.highlight
|
||||
else:
|
||||
highlight = None
|
||||
|
||||
shape = body.shape
|
||||
if isinstance(shape, Circle):
|
||||
angle = interpolate_angle(body.previous_angle, body.angle, alpha)
|
||||
draw_circle(
|
||||
self.screen,
|
||||
color,
|
||||
outline,
|
||||
center,
|
||||
shape.radius,
|
||||
outline_width=ui.outline_width,
|
||||
angle=angle,
|
||||
marker_length=ui.marker_length,
|
||||
)
|
||||
if highlight is not None:
|
||||
pygame.draw.aacircle(
|
||||
self.screen, highlight, center, shape.radius, ui.highlight_width
|
||||
)
|
||||
else:
|
||||
half_extents = shape.half_extents()
|
||||
draw_box(
|
||||
self.screen, color, outline, center, half_extents, outline_width=ui.outline_width
|
||||
)
|
||||
if highlight is not None:
|
||||
draw_box(
|
||||
self.screen,
|
||||
color,
|
||||
highlight,
|
||||
center,
|
||||
half_extents,
|
||||
outline_width=ui.highlight_width,
|
||||
)
|
||||
|
||||
def _draw_throw_arrow(self, body: Body, velocity: Vector2) -> None:
|
||||
"""Linie in Wurfrichtung; ihre Länge zeigt, wie weit der Körper in kurzer Zeit flöge."""
|
||||
if velocity.length_squared() == 0.0:
|
||||
return
|
||||
ui = self.style.ui
|
||||
start = self.viewport.to_screen(body.position)
|
||||
end = (
|
||||
start[0] + velocity.x * ui.throw_arrow_time,
|
||||
start[1] + velocity.y * ui.throw_arrow_time,
|
||||
)
|
||||
color = self.style.theme.throw_arrow
|
||||
pygame.draw.line(self.screen, color, start, end, ui.outline_width)
|
||||
pygame.draw.aacircle(self.screen, color, end, ui.debug_contact_radius)
|
||||
|
||||
def _draw_debug(self, world: World, lines: list[str]) -> None:
|
||||
"""Kontaktpunkte, Kontaktnormalen und Kennzahlen."""
|
||||
theme = self.style.theme
|
||||
ui = self.style.ui
|
||||
for contact in world.contacts:
|
||||
point = self.viewport.to_screen(contact.point)
|
||||
end = (
|
||||
point[0] + contact.normal.x * ui.debug_normal_length,
|
||||
point[1] + contact.normal.y * ui.debug_normal_length,
|
||||
)
|
||||
pygame.draw.line(self.screen, theme.debug_normal, point, end)
|
||||
pygame.draw.circle(self.screen, theme.debug_contact, point, ui.debug_contact_radius)
|
||||
x = self.viewport.left + ui.padding
|
||||
y = self.viewport.top + ui.padding
|
||||
for line in lines:
|
||||
rendered = self.style.render_text(line, self.style.small_font, theme.text)
|
||||
self.screen.blit(rendered, (x, y))
|
||||
y += rendered.get_height()
|
||||
|
||||
def _draw_pause_badge(self) -> None:
|
||||
theme = self.style.theme
|
||||
ui = self.style.ui
|
||||
text = self.style.render_text(
|
||||
"PAUSE – Leertaste zum Fortsetzen", self.style.label_font, theme.text
|
||||
)
|
||||
badge = text.get_rect().inflate(2 * ui.padding, ui.padding)
|
||||
badge.midtop = (self.viewport.left + self.viewport.width // 2, ui.padding)
|
||||
pygame.draw.rect(self.screen, theme.panel_active, badge, border_radius=ui.corner_radius)
|
||||
self.screen.blit(text, text.get_rect(center=badge.center))
|
||||
|
||||
# ------------------------------------------------------------------ Vorschau
|
||||
|
||||
def draw_preview(self, preview: SpawnPreview) -> None:
|
||||
"""Zeichnet die halbtransparente Vorschau eines neuen Objekts."""
|
||||
theme = self.style.theme
|
||||
ui = self.style.ui
|
||||
half_width, half_height = preview.half_extents
|
||||
margin = ui.highlight_width
|
||||
size = (math.ceil(2 * half_width) + 2 * margin, math.ceil(2 * half_height) + 2 * margin)
|
||||
layer = pygame.Surface(size, pygame.SRCALPHA)
|
||||
fill = (*preview.color, theme.preview_alpha)
|
||||
outline = (*theme.highlight, theme.preview_alpha)
|
||||
local_center = (size[0] / 2, size[1] / 2)
|
||||
if preview.shape is ShapeKind.CIRCLE:
|
||||
pygame.draw.aacircle(layer, fill, local_center, half_width)
|
||||
pygame.draw.aacircle(layer, outline, local_center, half_width, ui.outline_width)
|
||||
else:
|
||||
draw_box(
|
||||
layer,
|
||||
fill,
|
||||
outline,
|
||||
local_center,
|
||||
preview.half_extents,
|
||||
outline_width=ui.outline_width,
|
||||
)
|
||||
self.screen.blit(
|
||||
layer, (preview.screen_center[0] - size[0] / 2, preview.screen_center[1] - size[1] / 2)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Bedienoberfläche: Widgets und die Werkzeugleiste (Sidebar)."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Zeichenhelfer für Körperformen – gemeinsam genutzt von Renderer und Vorschau-Kachel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TypeAlias
|
||||
|
||||
import pygame
|
||||
|
||||
#: RGB- oder RGBA-Farbe (RGBA für halbtransparente Vorschauen).
|
||||
DrawColor: TypeAlias = tuple[int, ...]
|
||||
|
||||
|
||||
def draw_circle(
|
||||
surface: pygame.Surface,
|
||||
color: DrawColor,
|
||||
outline: DrawColor,
|
||||
center: tuple[float, float],
|
||||
radius: float,
|
||||
*,
|
||||
outline_width: int,
|
||||
angle: float | None = None,
|
||||
marker_length: float = 0.0,
|
||||
) -> None:
|
||||
"""Zeichnet einen geglätteten Kreis mit Umrandung und optionalem Drehmarker.
|
||||
|
||||
Args:
|
||||
surface: Zielfläche.
|
||||
color: Füllfarbe.
|
||||
outline: Farbe von Umrandung und Drehmarker.
|
||||
center: Mittelpunkt in Bildschirmkoordinaten.
|
||||
radius: Radius in px.
|
||||
outline_width: Linienstärke der Umrandung.
|
||||
angle: Drehwinkel in rad. Ohne Marker wäre die Drehung eines einfarbigen Kreises
|
||||
unsichtbar – man sähe nicht, ob er rollt oder rutscht.
|
||||
marker_length: Länge des Markers als Anteil des Radius.
|
||||
"""
|
||||
# Die Füllung braucht keine Kantenglättung: Die geglättete Umrandung liegt genau auf
|
||||
# ihrem Rand und verdeckt die Treppenstufen. Das halbiert die teuren aacircle-Aufrufe.
|
||||
pygame.draw.circle(surface, color, center, radius)
|
||||
pygame.draw.aacircle(surface, outline, center, radius, outline_width)
|
||||
if angle is not None:
|
||||
end = (
|
||||
center[0] + math.cos(angle) * radius * marker_length,
|
||||
center[1] + math.sin(angle) * radius * marker_length,
|
||||
)
|
||||
pygame.draw.line(surface, outline, center, end, outline_width)
|
||||
|
||||
|
||||
def draw_box(
|
||||
surface: pygame.Surface,
|
||||
color: DrawColor,
|
||||
outline: DrawColor,
|
||||
center: tuple[float, float],
|
||||
half_extents: tuple[float, float],
|
||||
*,
|
||||
outline_width: int,
|
||||
corner_radius: int = 0,
|
||||
) -> None:
|
||||
"""Zeichnet ein achsenparalleles Rechteck mit Umrandung."""
|
||||
half_width, half_height = half_extents
|
||||
rect = pygame.Rect(0, 0, round(2 * half_width), round(2 * half_height))
|
||||
rect.center = (round(center[0]), round(center[1]))
|
||||
pygame.draw.rect(surface, color, rect, border_radius=corner_radius)
|
||||
pygame.draw.rect(surface, outline, rect, outline_width, border_radius=corner_radius)
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Werkzeugleiste am linken Fensterrand. Sie gehört nicht zur Physikwelt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import Protocol
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import Color, InteractionConfig
|
||||
from sandbox.interaction import ShapeKind, ToolSettings
|
||||
from sandbox.ui.style import UiStyle
|
||||
from sandbox.ui.widgets import (
|
||||
Button,
|
||||
ColorPicker,
|
||||
Label,
|
||||
LabelKind,
|
||||
SegmentedControl,
|
||||
Slider,
|
||||
SpawnTile,
|
||||
Widget,
|
||||
)
|
||||
|
||||
|
||||
class SidebarControls(Protocol):
|
||||
"""Was die Sidebar von der Anwendung braucht (erfüllt von :class:`~sandbox.main.App`)."""
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
"""Ob die Simulation pausiert ist."""
|
||||
...
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
"""Pausiert bzw. setzt fort."""
|
||||
...
|
||||
|
||||
def gravity_enabled(self) -> bool:
|
||||
"""Ob die Schwerkraft wirkt."""
|
||||
...
|
||||
|
||||
def toggle_gravity(self) -> None:
|
||||
"""Schaltet die Schwerkraft um."""
|
||||
...
|
||||
|
||||
def clear_world(self) -> None:
|
||||
"""Entfernt alle Körper."""
|
||||
...
|
||||
|
||||
def begin_spawn_drag(self) -> None:
|
||||
"""Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel."""
|
||||
...
|
||||
|
||||
def preview_color(self) -> Color:
|
||||
"""Farbe des nächsten Objekts."""
|
||||
...
|
||||
|
||||
def status_lines(self) -> list[str]:
|
||||
"""Statuszeilen für den unteren Rand (Objektzahl, FPS …)."""
|
||||
...
|
||||
|
||||
|
||||
def _format_size(value: float) -> str:
|
||||
return f"{value:.0f} px"
|
||||
|
||||
|
||||
def _format_ratio(value: float) -> str:
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
def _format_density(value: float) -> str:
|
||||
return f"{value:.1f}"
|
||||
|
||||
|
||||
class Sidebar:
|
||||
"""Baut die Bedienelemente auf, verteilt Events an sie und zeichnet sie.
|
||||
|
||||
Args:
|
||||
rect: Bereich der Sidebar auf dem Bildschirm.
|
||||
style: Farben, Schriften, Maße.
|
||||
tools: Die Werkzeug-Einstellungen, die die Regler verändern.
|
||||
limits: Wertebereiche der Regler.
|
||||
palette: Farbpalette.
|
||||
controls: Anbindung an die Anwendung.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
style: UiStyle,
|
||||
tools: ToolSettings,
|
||||
limits: InteractionConfig,
|
||||
palette: tuple[Color, ...],
|
||||
controls: SidebarControls,
|
||||
) -> None:
|
||||
self.rect = pygame.Rect(rect)
|
||||
self.style = style
|
||||
self.tools = tools
|
||||
self.controls = controls
|
||||
self.widgets: list[Widget] = []
|
||||
self._status_top = 0
|
||||
self._build(limits, palette)
|
||||
|
||||
# ------------------------------------------------------------------ Aufbau
|
||||
|
||||
def _build(self, limits: InteractionConfig, palette: tuple[Color, ...]) -> None:
|
||||
"""Ordnet die Widgets von oben nach unten an."""
|
||||
ui = self.style.ui
|
||||
tools = self.tools
|
||||
controls = self.controls
|
||||
left = self.rect.x + ui.padding
|
||||
width = self.rect.width - 2 * ui.padding
|
||||
y = self.rect.y + ui.padding
|
||||
|
||||
def row(height: int) -> pygame.Rect:
|
||||
return pygame.Rect(left, y, width, height)
|
||||
|
||||
def add(widget: Widget, gap: int = ui.row_gap) -> None:
|
||||
nonlocal y
|
||||
self.widgets.append(widget)
|
||||
y = widget.rect.bottom + gap
|
||||
|
||||
def slider(
|
||||
name: str,
|
||||
label: str,
|
||||
value_format: Callable[[float], str],
|
||||
*,
|
||||
minimum: float,
|
||||
maximum: float,
|
||||
step: float,
|
||||
is_enabled: Callable[[], bool] | None = None,
|
||||
) -> Slider:
|
||||
# Der Regler liest und schreibt direkt das gleichnamige Attribut der Einstellungen.
|
||||
return Slider(
|
||||
row(ui.slider_height),
|
||||
label,
|
||||
minimum=minimum,
|
||||
maximum=maximum,
|
||||
step=step,
|
||||
get_value=partial(getattr, tools, name),
|
||||
set_value=partial(setattr, tools, name),
|
||||
value_format=value_format,
|
||||
knob_radius=ui.slider_knob_radius,
|
||||
is_enabled=is_enabled,
|
||||
name=name,
|
||||
)
|
||||
|
||||
add(Label(row(ui.header_height + ui.padding), "Physik-Sandbox", LabelKind.TITLE))
|
||||
|
||||
add(Label(row(ui.header_height), "OBJEKT", LabelKind.HEADER))
|
||||
add(
|
||||
SegmentedControl(
|
||||
row(ui.button_height),
|
||||
[(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")],
|
||||
partial(getattr, tools, "shape"),
|
||||
partial(setattr, tools, "shape"),
|
||||
name="shape",
|
||||
)
|
||||
)
|
||||
add(
|
||||
slider(
|
||||
"size",
|
||||
"Größe",
|
||||
_format_size,
|
||||
minimum=limits.min_size,
|
||||
maximum=limits.max_size,
|
||||
step=limits.size_step,
|
||||
)
|
||||
)
|
||||
add(
|
||||
slider(
|
||||
"aspect_ratio",
|
||||
"Seitenverhältnis",
|
||||
_format_ratio,
|
||||
minimum=limits.min_aspect_ratio,
|
||||
maximum=limits.max_aspect_ratio,
|
||||
step=limits.aspect_ratio_step,
|
||||
is_enabled=lambda: tools.shape is ShapeKind.BOX,
|
||||
),
|
||||
ui.section_gap,
|
||||
)
|
||||
|
||||
add(Label(row(ui.header_height), "MATERIAL", LabelKind.HEADER))
|
||||
add(
|
||||
slider(
|
||||
"restitution",
|
||||
"Elastizität",
|
||||
_format_ratio,
|
||||
minimum=0.0,
|
||||
maximum=limits.max_restitution,
|
||||
step=limits.restitution_step,
|
||||
)
|
||||
)
|
||||
add(
|
||||
slider(
|
||||
"density",
|
||||
"Dichte",
|
||||
_format_density,
|
||||
minimum=limits.min_density,
|
||||
maximum=limits.max_density,
|
||||
step=limits.density_step,
|
||||
)
|
||||
)
|
||||
add(Label(row(ui.header_height), "FARBE", LabelKind.HEADER))
|
||||
add(
|
||||
ColorPicker(
|
||||
row(ui.swatch_size),
|
||||
palette,
|
||||
partial(getattr, tools, "color_index"),
|
||||
partial(setattr, tools, "color_index"),
|
||||
swatch_size=ui.swatch_size,
|
||||
swatch_gap=ui.swatch_gap,
|
||||
name="color",
|
||||
),
|
||||
ui.section_gap,
|
||||
)
|
||||
add(
|
||||
SpawnTile(
|
||||
row(ui.spawn_tile_height),
|
||||
lambda: tools,
|
||||
controls.preview_color,
|
||||
controls.begin_spawn_drag,
|
||||
hint="Ziehen oder in die Welt klicken",
|
||||
name="spawn_tile",
|
||||
),
|
||||
ui.section_gap,
|
||||
)
|
||||
|
||||
add(Label(row(ui.header_height), "SIMULATION", LabelKind.HEADER))
|
||||
half = (width - ui.row_gap) // 2
|
||||
self.widgets.append(
|
||||
Button(
|
||||
pygame.Rect(left, y, half, ui.button_height),
|
||||
lambda: "Weiter" if controls.is_paused() else "Pause",
|
||||
controls.toggle_pause,
|
||||
is_active=controls.is_paused,
|
||||
name="pause",
|
||||
)
|
||||
)
|
||||
add(
|
||||
Button(
|
||||
pygame.Rect(
|
||||
left + half + ui.row_gap, y, width - half - ui.row_gap, ui.button_height
|
||||
),
|
||||
lambda: "Schwerkraft an" if controls.gravity_enabled() else "Schwerkraft aus",
|
||||
controls.toggle_gravity,
|
||||
is_active=controls.gravity_enabled,
|
||||
name="gravity",
|
||||
)
|
||||
)
|
||||
add(
|
||||
Button(
|
||||
row(ui.button_height),
|
||||
"Alles löschen",
|
||||
controls.clear_world,
|
||||
danger=True,
|
||||
name="clear",
|
||||
),
|
||||
ui.section_gap,
|
||||
)
|
||||
self._status_top = y
|
||||
|
||||
# ------------------------------------------------------------------ Laufzeit
|
||||
|
||||
def widget(self, name: str) -> Widget:
|
||||
"""Findet ein Widget über seinen Namen."""
|
||||
for widget in self.widgets:
|
||||
if widget.name == name:
|
||||
return widget
|
||||
raise KeyError(name)
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Leitet ein Event an die Widgets weiter.
|
||||
|
||||
Returns:
|
||||
True, wenn ein Widget das Event verbraucht hat oder ein Klick auf die Sidebar
|
||||
fiel – solche Klicks dürfen nie in der Welt landen.
|
||||
"""
|
||||
if event.type == pygame.MOUSEMOTION:
|
||||
# Alle Widgets sehen jede Mausbewegung (Hover-Zustand, gezogene Regler).
|
||||
results = [widget.handle_event(event, mouse_pos) for widget in self.widgets]
|
||||
return any(results)
|
||||
for widget in self.widgets:
|
||||
if widget.handle_event(event, mouse_pos):
|
||||
return True
|
||||
is_click = event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP)
|
||||
return is_click and self.rect.collidepoint(mouse_pos)
|
||||
|
||||
def draw(self, surface: pygame.Surface) -> None:
|
||||
"""Zeichnet Hintergrund, Widgets und Statuszeilen."""
|
||||
style = self.style
|
||||
pygame.draw.rect(surface, style.theme.sidebar_background, self.rect)
|
||||
for widget in self.widgets:
|
||||
widget.draw(surface, style)
|
||||
y = self._status_top
|
||||
for line in self.controls.status_lines():
|
||||
rendered = style.render_text(line, style.small_font, style.theme.text_dim)
|
||||
surface.blit(rendered, (self.rect.x + style.ui.padding, y))
|
||||
y += rendered.get_height()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Gemeinsamer Zeichenstil: Farben, Schriften und ein kleiner Text-Cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import Color, Theme, UiConfig
|
||||
|
||||
|
||||
def _load_font(names: tuple[str, ...], size: int, *, bold: bool = False) -> pygame.font.Font:
|
||||
"""Lädt die erste verfügbare Systemschrift, sonst die eingebaute pygame-Schrift."""
|
||||
for name in names:
|
||||
path = pygame.font.match_font(name)
|
||||
if path:
|
||||
font = pygame.font.Font(path, size)
|
||||
break
|
||||
else:
|
||||
font = pygame.font.Font(None, size)
|
||||
font.bold = bold
|
||||
return font
|
||||
|
||||
|
||||
class UiStyle:
|
||||
"""Farben, Maße und Schriften, die alle Widgets und der Renderer gemeinsam nutzen.
|
||||
|
||||
Args:
|
||||
theme: Farbschema.
|
||||
ui: Schriften und Maße.
|
||||
"""
|
||||
|
||||
def __init__(self, theme: Theme, ui: UiConfig) -> None:
|
||||
if not pygame.font.get_init():
|
||||
pygame.font.init()
|
||||
self.theme = theme
|
||||
self.ui = ui
|
||||
self.title_font = _load_font(ui.font_names, ui.title_font_size, bold=True)
|
||||
self.label_font = _load_font(ui.font_names, ui.label_font_size)
|
||||
self.small_font = _load_font(ui.font_names, ui.small_font_size)
|
||||
self._text_cache: dict[tuple[int, str, Color], pygame.Surface] = {}
|
||||
|
||||
def render_text(self, text: str, font: pygame.font.Font, color: Color) -> pygame.Surface:
|
||||
"""Rendert Text (mit Zwischenspeicher, da sich die meisten Texte selten ändern)."""
|
||||
key = (id(font), text, color)
|
||||
surface = self._text_cache.get(key)
|
||||
if surface is None:
|
||||
if len(self._text_cache) >= self.ui.text_cache_size:
|
||||
self._text_cache.clear() # einfach und ausreichend: selten, dann komplett
|
||||
surface = font.render(text, True, color)
|
||||
self._text_cache[key] = surface
|
||||
return surface
|
||||
|
||||
|
||||
def shade(color: Color, factor: float) -> Color:
|
||||
"""Verdunkelt (factor < 1) oder erhellt (factor > 1) eine Farbe."""
|
||||
return (
|
||||
max(0, min(255, round(color[0] * factor))),
|
||||
max(0, min(255, round(color[1] * factor))),
|
||||
max(0, min(255, round(color[2] * factor))),
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Selbst gezeichnete Bedienelemente: Beschriftung, Button, Umschalter, Regler, Farbwahl.
|
||||
|
||||
Jedes Widget hat ein Rechteck, verarbeitet pygame-Events (:meth:`Widget.handle_event`
|
||||
liefert True, wenn es das Event verbraucht hat) und zeichnet sich selbst. Zustände wie
|
||||
"ist die Pause aktiv?" fragen die Widgets über Callbacks ab, statt sie zu kopieren – so
|
||||
zeigen sie immer den aktuellen Stand, auch wenn er per Tastenkürzel geändert wurde.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from enum import Enum
|
||||
from typing import Generic, TypeAlias, TypeVar
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import Color
|
||||
from sandbox.interaction import ShapeKind, ToolSettings
|
||||
from sandbox.ui.drawing import draw_box, draw_circle
|
||||
from sandbox.ui.style import UiStyle, shade
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
#: Fester Text oder Funktion, die den aktuellen Text liefert.
|
||||
TextSource: TypeAlias = str | Callable[[], str]
|
||||
|
||||
|
||||
def _resolve(text: TextSource) -> str:
|
||||
return text() if callable(text) else text
|
||||
|
||||
|
||||
def _is_left_press(event: pygame.event.Event, rect: pygame.Rect) -> bool:
|
||||
return (
|
||||
event.type == pygame.MOUSEBUTTONDOWN
|
||||
and event.button == pygame.BUTTON_LEFT
|
||||
and rect.collidepoint(event.pos)
|
||||
)
|
||||
|
||||
|
||||
def _is_left_release(event: pygame.event.Event) -> bool:
|
||||
return event.type == pygame.MOUSEBUTTONUP and event.button == pygame.BUTTON_LEFT
|
||||
|
||||
|
||||
class Widget:
|
||||
"""Basisklasse aller Bedienelemente.
|
||||
|
||||
Args:
|
||||
rect: Position und Größe auf dem Bildschirm.
|
||||
name: Eindeutiger Name, über den z. B. Tests das Widget finden.
|
||||
"""
|
||||
|
||||
def __init__(self, rect: pygame.Rect, name: str = "") -> None:
|
||||
self.rect = pygame.Rect(rect)
|
||||
self.name = name
|
||||
self.hovered = False
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Verarbeitet ein Event; True, wenn das Widget es verbraucht hat."""
|
||||
if event.type == pygame.MOUSEMOTION:
|
||||
self.hovered = self.rect.collidepoint(mouse_pos)
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet das Widget."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LabelKind(Enum):
|
||||
"""Darstellungsart einer Beschriftung."""
|
||||
|
||||
TITLE = "title"
|
||||
HEADER = "header"
|
||||
TEXT = "text"
|
||||
HINT = "hint"
|
||||
|
||||
|
||||
class Label(Widget):
|
||||
"""Einzeilige Beschriftung (Text fest oder dynamisch über eine Funktion)."""
|
||||
|
||||
def __init__(
|
||||
self, rect: pygame.Rect, text: TextSource, kind: LabelKind = LabelKind.TEXT, name: str = ""
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.text = text
|
||||
self.kind = kind
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet den Text linksbündig und vertikal zentriert."""
|
||||
theme = style.theme
|
||||
font, color = {
|
||||
LabelKind.TITLE: (style.title_font, theme.text),
|
||||
LabelKind.HEADER: (style.small_font, theme.text_dim),
|
||||
LabelKind.TEXT: (style.label_font, theme.text),
|
||||
LabelKind.HINT: (style.small_font, theme.text_dim),
|
||||
}[self.kind]
|
||||
rendered = style.render_text(_resolve(self.text), font, color)
|
||||
surface.blit(rendered, (self.rect.x, self.rect.centery - rendered.get_height() // 2))
|
||||
|
||||
|
||||
class Button(Widget):
|
||||
"""Schaltfläche. Löst beim Loslassen über dem Button aus (übliches UI-Verhalten).
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
text: Beschriftung (fest oder dynamisch).
|
||||
on_click: Wird beim Klick aufgerufen.
|
||||
is_active: Optional; hebt den Button hervor, solange die Funktion True liefert.
|
||||
danger: Rote Darstellung für zerstörerische Aktionen.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
text: TextSource,
|
||||
on_click: Callable[[], None],
|
||||
*,
|
||||
is_active: Callable[[], bool] | None = None,
|
||||
danger: bool = False,
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.text = text
|
||||
self.on_click = on_click
|
||||
self.is_active = is_active
|
||||
self.danger = danger
|
||||
self.pressed = False
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Drücken merkt sich den Button, Loslassen über dem Button löst aus."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
if _is_left_press(event, self.rect):
|
||||
self.pressed = True
|
||||
return True
|
||||
if _is_left_release(event) and self.pressed:
|
||||
self.pressed = False
|
||||
if self.rect.collidepoint(event.pos):
|
||||
self.on_click()
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet Hintergrund (je nach Zustand) und zentrierten Text."""
|
||||
theme = style.theme
|
||||
active = self.is_active is not None and self.is_active()
|
||||
if active:
|
||||
background = theme.panel_active
|
||||
elif self.hovered:
|
||||
background = theme.panel_hover
|
||||
else:
|
||||
background = theme.panel
|
||||
radius = style.ui.corner_radius
|
||||
pygame.draw.rect(surface, background, self.rect, border_radius=radius)
|
||||
text_color = theme.danger if self.danger else theme.text
|
||||
if self.danger or active:
|
||||
border = theme.danger if self.danger else theme.accent
|
||||
pygame.draw.rect(
|
||||
surface, border, self.rect, style.ui.border_width, border_radius=radius
|
||||
)
|
||||
rendered = style.render_text(_resolve(self.text), style.label_font, text_color)
|
||||
surface.blit(rendered, rendered.get_rect(center=self.rect.center))
|
||||
|
||||
|
||||
class SegmentedControl(Widget, Generic[T]):
|
||||
"""Reihe gleich breiter Optionen, von denen genau eine gewählt ist.
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
options: Paare aus Wert und Beschriftung.
|
||||
get_value: Liefert den aktuell gewählten Wert.
|
||||
set_value: Setzt einen neuen Wert.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
options: Sequence[tuple[T, str]],
|
||||
get_value: Callable[[], T],
|
||||
set_value: Callable[[T], None],
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.options = list(options)
|
||||
self.get_value = get_value
|
||||
self.set_value = set_value
|
||||
|
||||
def segment_rects(self) -> list[pygame.Rect]:
|
||||
"""Rechtecke der einzelnen Optionen."""
|
||||
count = len(self.options)
|
||||
rects = []
|
||||
for index in range(count):
|
||||
left = self.rect.x + self.rect.width * index // count
|
||||
right = self.rect.x + self.rect.width * (index + 1) // count
|
||||
rects.append(pygame.Rect(left, self.rect.y, right - left, self.rect.height))
|
||||
return rects
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Ein Klick auf eine Option wählt sie sofort aus."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
for (value, _), rect in zip(self.options, self.segment_rects(), strict=True):
|
||||
if _is_left_press(event, rect):
|
||||
self.set_value(value)
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet alle Optionen, die gewählte hervorgehoben."""
|
||||
theme = style.theme
|
||||
radius = style.ui.corner_radius
|
||||
pygame.draw.rect(surface, theme.panel, self.rect, border_radius=radius)
|
||||
current = self.get_value()
|
||||
for (value, text), rect in zip(self.options, self.segment_rects(), strict=True):
|
||||
selected = value == current
|
||||
if selected:
|
||||
pygame.draw.rect(surface, theme.panel_active, rect, border_radius=radius)
|
||||
pygame.draw.rect(
|
||||
surface, theme.accent, rect, style.ui.border_width, border_radius=radius
|
||||
)
|
||||
color = theme.text if selected else theme.text_dim
|
||||
rendered = style.render_text(text, style.label_font, color)
|
||||
surface.blit(rendered, rendered.get_rect(center=rect.center))
|
||||
|
||||
|
||||
class Slider(Widget):
|
||||
"""Schieberegler mit Beschriftung und Wertanzeige.
|
||||
|
||||
Oben stehen Beschriftung und aktueller Wert, darunter die Schiene mit Knopf. Der Wert
|
||||
lässt sich klicken, ziehen oder mit dem Mausrad (in ``step``-Schritten) ändern.
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
label: Beschriftung.
|
||||
minimum: Kleinster Wert.
|
||||
maximum: Größter Wert.
|
||||
step: Rasterung der Werte.
|
||||
get_value: Liefert den aktuellen Wert.
|
||||
set_value: Setzt einen neuen Wert.
|
||||
value_format: Formatiert den Wert für die Anzeige.
|
||||
knob_radius: Radius des Knopfs in px (bestimmt auch den Rand der Schiene).
|
||||
is_enabled: Optional; ein deaktivierter Regler ist ausgegraut und reagiert nicht.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
label: str,
|
||||
*,
|
||||
minimum: float,
|
||||
maximum: float,
|
||||
step: float,
|
||||
get_value: Callable[[], float],
|
||||
set_value: Callable[[float], None],
|
||||
value_format: Callable[[float], str],
|
||||
knob_radius: int,
|
||||
is_enabled: Callable[[], bool] | None = None,
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
if not (maximum > minimum and step > 0.0):
|
||||
raise ValueError("Der Regler braucht maximum > minimum und step > 0")
|
||||
self.label = label
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.step = step
|
||||
self.get_value = get_value
|
||||
self.set_value = set_value
|
||||
self.value_format = value_format
|
||||
self.knob_radius = knob_radius
|
||||
self.is_enabled = is_enabled
|
||||
self.dragging = False
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Ob der Regler bedienbar ist."""
|
||||
return self.is_enabled is None or self.is_enabled()
|
||||
|
||||
@property
|
||||
def track_left(self) -> int:
|
||||
"""x-Koordinate des linken Schienenendes."""
|
||||
return self.rect.x + self.knob_radius
|
||||
|
||||
@property
|
||||
def track_width(self) -> int:
|
||||
"""Breite der Schiene in px."""
|
||||
return self.rect.width - 2 * self.knob_radius
|
||||
|
||||
@property
|
||||
def track_y(self) -> int:
|
||||
"""y-Koordinate der Schienenmitte."""
|
||||
return self.rect.bottom - self.knob_radius
|
||||
|
||||
def snap(self, value: float) -> float:
|
||||
"""Rastet einen Wert auf das Raster ein und begrenzt ihn auf [minimum, maximum]."""
|
||||
steps = round((value - self.minimum) / self.step)
|
||||
snapped = self.minimum + steps * self.step
|
||||
return min(max(snapped, self.minimum), self.maximum)
|
||||
|
||||
def value_at(self, x: float) -> float:
|
||||
"""Wert, der zur Bildschirmposition ``x`` auf der Schiene gehört."""
|
||||
fraction = (x - self.track_left) / self.track_width
|
||||
fraction = min(max(fraction, 0.0), 1.0)
|
||||
return self.snap(self.minimum + fraction * (self.maximum - self.minimum))
|
||||
|
||||
def x_of(self, value: float) -> float:
|
||||
"""Bildschirmposition ``x`` eines Werts (Umkehrung von :meth:`value_at`)."""
|
||||
fraction = (value - self.minimum) / (self.maximum - self.minimum)
|
||||
return self.track_left + fraction * self.track_width
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Klicken, Ziehen und Mausrad ändern den Wert."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
if not self.enabled:
|
||||
self.dragging = False
|
||||
return _is_left_press(event, self.rect) # Klick verschlucken, nichts tun
|
||||
if _is_left_press(event, self.rect):
|
||||
self.dragging = True
|
||||
self.set_value(self.value_at(event.pos[0]))
|
||||
return True
|
||||
if event.type == pygame.MOUSEMOTION and self.dragging:
|
||||
self.set_value(self.value_at(mouse_pos[0]))
|
||||
return True
|
||||
if _is_left_release(event) and self.dragging:
|
||||
self.dragging = False
|
||||
return True
|
||||
if event.type == pygame.MOUSEWHEEL and self.rect.collidepoint(mouse_pos):
|
||||
self.set_value(self.snap(self.get_value() + event.y * self.step))
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet Beschriftung, Wert, Schiene und Knopf."""
|
||||
theme = style.theme
|
||||
enabled = self.enabled
|
||||
text_color = theme.text if enabled else theme.text_dim
|
||||
label = style.render_text(self.label, style.label_font, text_color)
|
||||
surface.blit(label, (self.rect.x, self.rect.y))
|
||||
value = style.render_text(
|
||||
self.value_format(self.get_value()), style.small_font, theme.text_dim
|
||||
)
|
||||
# Unterkanten bündig: Der kleinere Wert steht so auf derselben Grundlinie wie der Text.
|
||||
label_bottom = self.rect.y + label.get_height()
|
||||
surface.blit(value, value.get_rect(bottomright=(self.rect.right, label_bottom)))
|
||||
|
||||
track = pygame.Rect(0, 0, self.track_width, style.ui.slider_track_height)
|
||||
track.midleft = (self.track_left, self.track_y)
|
||||
pygame.draw.rect(surface, theme.panel_hover, track, border_radius=track.height)
|
||||
knob_x = round(self.x_of(self.get_value()))
|
||||
filled = track.copy()
|
||||
filled.width = max(0, knob_x - track.x)
|
||||
fill_color = theme.accent if enabled else theme.text_dim
|
||||
pygame.draw.rect(surface, fill_color, filled, border_radius=track.height)
|
||||
knob_color = theme.text if (self.hovered or self.dragging) and enabled else fill_color
|
||||
pygame.draw.aacircle(surface, knob_color, (knob_x, self.track_y), self.knob_radius)
|
||||
|
||||
|
||||
class ColorPicker(Widget):
|
||||
"""Farbfelder zur Auswahl; das erste Feld steht für "Zufallsfarbe".
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
colors: Farbpalette.
|
||||
get_index: Liefert den gewählten Palettenindex (``None`` = Zufall).
|
||||
set_index: Setzt den Palettenindex (``None`` = Zufall).
|
||||
swatch_size: Kantenlänge eines Farbfelds in px.
|
||||
swatch_gap: Abstand zwischen den Feldern in px.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
colors: Sequence[Color],
|
||||
get_index: Callable[[], int | None],
|
||||
set_index: Callable[[int | None], None],
|
||||
*,
|
||||
swatch_size: int,
|
||||
swatch_gap: int,
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.colors = list(colors)
|
||||
self.get_index = get_index
|
||||
self.set_index = set_index
|
||||
self.swatch_size = swatch_size
|
||||
self.swatch_gap = swatch_gap
|
||||
|
||||
def swatches(self) -> list[tuple[int | None, pygame.Rect]]:
|
||||
"""Alle Farbfelder mit ihrem Index (zuerst das Zufallsfeld)."""
|
||||
indices: list[int | None] = [None, *range(len(self.colors))]
|
||||
pitch = self.swatch_size + self.swatch_gap
|
||||
return [
|
||||
(
|
||||
index,
|
||||
pygame.Rect(
|
||||
self.rect.x + i * pitch, self.rect.y, self.swatch_size, self.swatch_size
|
||||
),
|
||||
)
|
||||
for i, index in enumerate(indices)
|
||||
]
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Ein Klick auf ein Farbfeld wählt es aus."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
for index, rect in self.swatches():
|
||||
if _is_left_press(event, rect):
|
||||
self.set_index(index)
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet die Felder; das gewählte bekommt einen hellen Ring."""
|
||||
theme = style.theme
|
||||
selected = self.get_index()
|
||||
for index, rect in self.swatches():
|
||||
if index is None:
|
||||
# Zufallsfeld: vier Palettenfarben als Viertel.
|
||||
half = rect.width // 2
|
||||
quarters = self.colors[:: max(1, len(self.colors) // 4)][:4]
|
||||
for k, color in enumerate(quarters):
|
||||
quarter = pygame.Rect(
|
||||
rect.x + (k % 2) * half, rect.y + (k // 2) * half, half, half
|
||||
)
|
||||
pygame.draw.rect(surface, color, quarter)
|
||||
else:
|
||||
pygame.draw.rect(surface, self.colors[index], rect)
|
||||
if index == selected:
|
||||
gap = style.ui.selection_ring_gap
|
||||
ring = rect.inflate(2 * gap, 2 * gap)
|
||||
pygame.draw.rect(surface, theme.highlight, ring, style.ui.selection_ring_width)
|
||||
|
||||
|
||||
class SpawnTile(Widget):
|
||||
"""Vorschau-Kachel des nächsten Objekts; aus ihr zieht man Objekte in die Welt.
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
get_settings: Liefert die aktuellen Werkzeug-Einstellungen.
|
||||
get_color: Liefert die Farbe des nächsten Objekts.
|
||||
on_drag_start: Wird beim Drücken auf die Kachel aufgerufen.
|
||||
hint: Hinweistext unter der Vorschau.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
get_settings: Callable[[], ToolSettings],
|
||||
get_color: Callable[[], Color],
|
||||
on_drag_start: Callable[[], None],
|
||||
*,
|
||||
hint: str,
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.get_settings = get_settings
|
||||
self.get_color = get_color
|
||||
self.on_drag_start = on_drag_start
|
||||
self.hint = hint
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Drücken auf die Kachel beginnt das Herausziehen eines neuen Objekts."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
if _is_left_press(event, self.rect):
|
||||
self.on_drag_start()
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet Kachel, maßstäblich verkleinerte Vorschau und Hinweis."""
|
||||
theme = style.theme
|
||||
ui = style.ui
|
||||
background = theme.panel_hover if self.hovered else theme.panel
|
||||
pygame.draw.rect(surface, background, self.rect, border_radius=ui.corner_radius)
|
||||
hint = style.render_text(self.hint, style.small_font, theme.text_dim)
|
||||
hint_rect = hint.get_rect(midbottom=(self.rect.centerx, self.rect.bottom - ui.row_gap))
|
||||
surface.blit(hint, hint_rect)
|
||||
|
||||
settings = self.get_settings()
|
||||
half_width, half_height = settings.half_extents()
|
||||
area_height = hint_rect.top - self.rect.top
|
||||
available = min(self.rect.width, area_height) * ui.preview_scale_limit / 2
|
||||
scale = min(1.0, available / max(half_width, half_height))
|
||||
center = (self.rect.centerx, self.rect.top + area_height / 2)
|
||||
color = self.get_color()
|
||||
outline = shade(color, theme.outline_shade)
|
||||
if settings.shape is ShapeKind.CIRCLE:
|
||||
draw_circle(
|
||||
surface, color, outline, center, half_width * scale, outline_width=ui.outline_width
|
||||
)
|
||||
else:
|
||||
draw_box(
|
||||
surface,
|
||||
color,
|
||||
outline,
|
||||
center,
|
||||
(half_width * scale, half_height * scale),
|
||||
outline_width=ui.outline_width,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Abbildung zwischen Bildschirm- und Weltkoordinaten.
|
||||
|
||||
Die Welt liegt rechts neben der Sidebar. Weil beide Koordinatensysteme in Pixeln rechnen
|
||||
und die y-Achse gleich ausrichten, besteht die Abbildung nur aus einer Verschiebung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sandbox.physics import Vector2
|
||||
|
||||
|
||||
class Viewport:
|
||||
"""Rechteckiger Bildschirmbereich, in dem die Welt gezeichnet wird.
|
||||
|
||||
Args:
|
||||
left: Linker Rand auf dem Bildschirm in px.
|
||||
top: Oberer Rand auf dem Bildschirm in px.
|
||||
width: Breite in px (= Breite der Welt).
|
||||
height: Höhe in px (= Höhe der Welt).
|
||||
"""
|
||||
|
||||
def __init__(self, left: int, top: int, width: int, height: int) -> None:
|
||||
self.left = left
|
||||
self.top = top
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
@property
|
||||
def rect(self) -> tuple[int, int, int, int]:
|
||||
"""Bereich als ``(left, top, width, height)``."""
|
||||
return (self.left, self.top, self.width, self.height)
|
||||
|
||||
def contains(self, screen_pos: tuple[float, float]) -> bool:
|
||||
"""True, wenn ein Bildschirmpunkt in der Welt liegt."""
|
||||
x, y = screen_pos
|
||||
return self.left <= x < self.left + self.width and self.top <= y < self.top + self.height
|
||||
|
||||
def to_world(self, screen_pos: tuple[float, float]) -> Vector2:
|
||||
"""Bildschirmpunkt → Weltpunkt."""
|
||||
return Vector2(screen_pos[0] - self.left, screen_pos[1] - self.top)
|
||||
|
||||
def to_screen(self, point: Vector2) -> tuple[float, float]:
|
||||
"""Weltpunkt → Bildschirmpunkt."""
|
||||
return (point.x + self.left, point.y + self.top)
|
||||
Reference in New Issue
Block a user