İçeriğe geç / Skip to content / Zum Inhalt

Pogo-Physik in Godot: Eine Figur, die von selbst springt

Ahmet Balaman

6 Min. Lesezeit

Vibe CodingBOINGWARDGodotGDScriptSpielphysikJoltClaude Code
Pogo-Physik in Godot: Eine Figur, die von selbst springt

In BOINGWARD steht die Spielfigur auf einem Pogostab, der nie aufhört zu springen. Es gibt keine Sprungtaste; der Stab springt bei jeder Landung von selbst wieder ab, und der Spieler steuert nur in der Luft. In so einem Spiel muss sich die Physik nicht in erster Linie „gut anfühlen“, sondern vorhersehbar sein: Derselbe Anlauf und dieselbe Eingabe sollen jedes Mal fast dasselbe Ergebnis liefern. Sonst versteht man nach einem Sturz nicht, warum man gefallen ist.

Dieser Beitrag zeigt, wie diese Physik auf Godot 4.7 und Jolt aufgebaut wurde: Zustandsautomat, Kontaktregeln, Abschussformel, warum die erste Abstimmung kaputt war und wie getestet wird, dass die Physik nicht von der Bildrate abhängt. Wie das Spiel an einem Tag entstand, steht im Hauptbeitrag der Serie.

Ein RigidBody, aber jeder Schreibzugriff an einer Stelle

Der Pogostab ist ein RigidBody3D. Die Physik-Engine soll Kollisionen finden, aber die Bewegung wollen wir nicht der Reibung und dem Abprallen der Engine überlassen. Deshalb wird der Körper beim Start reduziert:

func _ready() -> void:
	gravity_scale = 0.0          # Schwerkraft wenden wir selbst an
	lock_rotation = true         # der Körper kippt nie; die Neigung ist nur optisch
	contact_monitor = true
	max_contacts_reported = 8
	continuous_cd = true         # bei schnellen Stürzen nicht durch den Boden fallen
	var material := PhysicsMaterial.new()
	material.friction = 0.0
	material.bounce = 0.0        # den Abprall berechnen wir, nicht die Engine
	physics_material_override = material

Die Regel selbst steht in der Regeldatei des Projekts: Jeder Schreibzugriff auf die Physik passiert in _integrate_forces. Das ist Godots Gegenstück zu Unitys FixedUpdate; es läuft im festen Physikschritt auf dem Körperzustand der Engine. Eingabe, Kamera und Oberfläche bleiben in _process. Der Pogostab wird nie über global_position bewegt; die einzige Ausnahme ist das Wiedereinsetzen, und auch das wird erst im nächsten Physikschritt angewendet.

Der Zustandsautomat

Der Pogostab hat fünf Zustände: RESPAWN, AIRBORNE, COMPRESSION, STUNNED und FINISHED. Jeder Physikschritt durchläuft dasselbe Gerüst:

func _integrate_forces(s: PhysicsDirectBodyState3D) -> void:
	_collect_contacts(s)
	var v := s.linear_velocity
	match state:
		State.COMPRESSION:
			v = _compress_surface_velocity      # beim Einfedern mit der Plattform mitfahren
			if _time >= _compression_end:
				v = _launch()
		State.AIRBORNE, State.STUNNED:
			v = _apply_zones(v, dt)             # Schwerkraft, Wind, geringe Schwerkraft
			v.y = maxf(v.y, -tuning.terminal_speed)
			if state == State.AIRBORNE:
				v = _steer(v, desired, dt)
			v = _evaluate_contacts(v)
	s.linear_velocity = v

Das Einfedern dauert 0,03 Sekunden. Das ist zu kurz, um es zu sehen, aber es sorgt dafür, dass der Pogostab diesen Moment auf einer beweglichen Plattform mit ihr zusammen verbringt.

Nicht jeder Kontakt ist ein Sprung

Damit aus einem Kontakt ein Sprung wird, müssen vier Bedingungen erfüllt sein:

  • Der Kontakt muss an der Spitze liegen: in den untersten 0,4 Metern des Körpers. Kontakte weiter oben sind Körpertreffer.
  • Die Oberfläche muss bespringbar sein: Flächen in der Gruppe no_bounce zählen nicht.
  • Der Pogostab darf sich nicht von der Fläche entfernen, und seit dem letzten Absprung müssen 0,12 Sekunden vergangen sein, damit derselbe Kontakt nie doppelt zählt.
  • Die Fläche muss flach genug sein: Der senkrechte Anteil ihrer Normalen muss mindestens 0,35 betragen. Berührt die Spitze etwas Steileres, prallt der Stab ab.

Damit kein Sprung verloren geht, nur weil ein Physikschritt den Kontakt kurz verliert, gilt ein Spitzenkontakt zwei weitere Schritte lang als „frisch“. Ein Körpertreffer schneller als 4 Meter pro Sekunde betäubt den Pogostab für 0,45 Sekunden und setzt die Kombo zurück. Einen langen Kontrollverlust gibt es nicht; die Strafe ist kurz und gut lesbar.

Die Abschussformel

Die eigentliche Entscheidung fällt beim Absprung, und diese Rechnung steckt in einer reinen Funktion, die die Szene nie berührt: PogoLaunchMath.compute. Weil sie rein ist, lassen sich leicht Unit-Tests schreiben.

Die Richtung ist eine gewichtete Summe aus drei Vektoren:

var dir := normal * t.normal_weight \           # 1.0: die Flächennormale
		+ pogo_up * t.pogo_weight \             # 1.4: die Neigung des Pogostabs
		+ tangential / t.momentum_reference_speed * t.momentum_weight   # 0.6: seitliche Geschwindigkeit
dir = dir.normalized()

Sie darf höchstens 75 Grad von der Flächennormalen abweichen. Die Geschwindigkeit ergibt sich so:

var speed := t.base_bounce_speed \              # 12 m/s
		+ impact * t.impact_transfer \          # ein kleiner Teil der Aufprallgeschwindigkeit
		+ minf(combo, t.max_combo) * t.combo_bonus   # höchstens 5 x 0.2
speed = clampf(speed, t.min_launch_speed, t.max_launch_speed)   # 8..22 m/s

Danach bleiben 60 Prozent der seitlichen Geschwindigkeit erhalten, die Gesamtgeschwindigkeit wird auf 26 m/s und die horizontale auf 10 m/s begrenzt, und bewegt sich die Plattform, kommt ihre Geschwindigkeit hinzu. Keine dieser Zahlen steht im Code; sie kommen aus einer Ressourcendatei namens PogoTuning. Magische Zahlen in der Logik sind verboten.

Warum die erste Abstimmung kaputt war

Im ersten Entwurf des Spiels wurden 35 Prozent der Aufprallgeschwindigkeit in den nächsten Absprung übertragen. Auf dem Papier klingt das vernünftig: Wer tiefer fällt, springt höher. Gemessen sah es anders aus. Ein Pogostab, der ohne Eingabe springt, pendelte sich bei etwa 21 Metern pro Sekunde ein und erreichte 9 Meter. Für einen Plattformer viel zu hoch.

Die Ursache ist eine einfache Rückkopplung. Die Landegeschwindigkeit entspricht ungefähr der vorherigen Absprunggeschwindigkeit; trägt jeder Absprung einen Teil des letzten mit, schaukelt sich die Geschwindigkeit auf. Der stationäre Zustand lässt sich so berechnen:

v = (Basis + Kombo-Bonus) / (1 - Übertragung)

Bei 0,35 Übertragung ist der Nenner 0,65; selbst eine kleine Basisgeschwindigkeit wächst. Die Übertragung wurde auf 0,05 gesenkt: (12 + 5 x 0,2) / 0,95 ≈ 13,7 m/s, also ein Scheitelpunkt von etwa 3,9 Metern. Das wurde zur Grundeinheit des Parcoursdesigns (3 Meter für den niedrigsten Sprung, 4 für einen eingependelten). Nach derselben Logik bekam auch die horizontale Geschwindigkeit eine Obergrenze; sonst ging jeder Sprung etwas weiter als der vorige, und die Sprungweite schaukelte sich auf.

Nachsicht für Touchscreens

Mit dem Daumen auf dem Telefon zu zielen ist schwerer als mit der Maus. Deshalb gibt es unsichtbare Hilfen, mit strengen Grenzen:

  • Landehilfe: Liegt der Pogostab weniger als 12 Grad neben der Flächennormalen, wird die Absprungrichtung um 60 Prozent zur Normalen hin gebogen. Schrägere Landungen bleiben unberührt.
  • Perfekte Landung: Eine Landung gerader als 6 Grad zählt als „perfekt“, aber nur nach einem echten Sprung von mindestens 3 Metern. Auf der Stelle zu hüpfen ergibt nie eine perfekte Landung.
  • Luftkontrolle: voll bei geringer Geschwindigkeit, schwächer bei höherer, bei 14 Metern pro Sekunde nur noch 35 Prozent.

Die Regel im Designdokument ist eindeutig: Hilfen übersetzen die Absicht, sie retten nie eine klar verfehlte Plattform. Die Landehilfe greift nur bei einem gültigen Kontakt; einen Pogostab in der Luft zieht sie nie zur Plattform.

Bewegliche Plattformen und Jolt

Ein Pogostab, der auf einer beweglichen Plattform landet, soll mitfahren. Hier gab es eine Überraschung: Jolt meldet für kinematische Körper am Kontaktpunkt die Geschwindigkeit null. Die Lösung: Jedes bewegliche Teil liefert seine Oberflächengeschwindigkeit über eine eigene Methode:

# Jolt meldet null für kinematische Körper, also liefern bewegliche Flächen ihre Geschwindigkeit selbst.
if collider != null and collider.has_method(&"surface_velocity_at"):
	sv = collider.surface_velocity_at(pos)

Beim Absprung rechnet der Pogostab relativ zur Plattform und übernimmt ihre horizontale Geschwindigkeit vollständig; springt er von einer gleitenden Plattform ab, rutscht sie ihm nicht unter den Füßen weg.

Test auf Unabhängigkeit von der Bildrate

Die Physik läuft mit 60 Schritten pro Sekunde, der Bildschirm zeichnet aber vielleicht mit 30, 60 oder 120 Bildern. Hängt das Physikergebnis von der Bildrate ab, wird eine Plattform, die auf einem Telefon machbar ist, auf einem anderen unmöglich. Deshalb wird die Eingabe in _process gelesen und gespeichert, im Physikschritt verbraucht, und die ganze Rechnung nutzt die Schrittdauer in _integrate_forces.

Ein Test prüft, ob die Regel hält. Das Testskript spielt dasselbe Szenario mit drei Bildraten durch und gibt eine einzeilige Signatur aus: Zahl der Sprünge, erreichte Scheitelhöhe und das Ergebnis eines festen Trickversuchs.

for fps in 30 60 144; do
  godot --headless --path . --fixed-fps "$fps" -s tests/run_tests.gd -- sig
done
# SIG bounces=… apex=… trick=…  alle drei Zeilen müssen exakt gleich sein

Weicht eine Zeile ab, wird die Testsuite rot. Für flüssige Darstellung ist Godots Physik-Interpolation aktiv; beim Wiedereinsetzen und Teleportieren wird sie zurückgesetzt, damit die Figur nicht sichtbar zwischen zwei Punkten gleitet. Das hat auch den 120-fps-Modus möglich gemacht: Die Physik bleibt bei 60 Schritten, nur das Zeichnen wird häufiger.

Häufig gestellte Fragen

CharacterBody oder RigidBody für Figurenphysik in Godot?

Für einen klassischen Plattformer ist CharacterBody3D oft einfacher. In BOINGWARD entscheidet sich die Geschwindigkeit fast vollständig beim Absprung, und der Kontakt mit beweglichen Flächen ist wichtig. Deshalb fiel die Wahl auf RigidBody3D; Reibung und Abprall sind abgeschaltet, die Geschwindigkeit wird in _integrate_forces von Hand geschrieben.

Warum wurde die Sprunghöhe von selbst immer größer?

Wenn jeder Absprung einen Teil der Aufprallgeschwindigkeit in den nächsten trägt, wächst die Geschwindigkeit durch Rückkopplung. Die stationäre Geschwindigkeit ist die Basisgeschwindigkeit geteilt durch (1 - Übertragung). Mit 0,05 statt 0,35 sank der Scheitelpunkt von 9 auf etwa 4 Meter.

Wie teste ich, dass Spielphysik bei verschiedenen FPS gleich läuft?

Verlegen Sie die Physikrechnung in den festen Schritt, lesen Sie die Eingabe im Zeichenframe und verbrauchen Sie sie im Physikschritt. Dann spielen Sie dasselbe Szenario mit --fixed-fps bei verschiedenen Bildraten ab und vergleichen eine kleine Ergebnissignatur. Die Signaturen müssen exakt übereinstimmen.

Warum trägt eine bewegliche Plattform die Figur in Jolt nicht mit?

Jolt meldet für kinematische Körper am Kontaktpunkt die Geschwindigkeit null. Das bewegliche Teil muss seine Geschwindigkeit über eine Methode liefern (hier surface_velocity_at), und die Figur muss diese Geschwindigkeit in die Absprungrechnung einbeziehen.

Kommentare