Praktisch elastisch (Linux-Magazin, April 2024)

Wer am Billiardtisch in der Kneipe beim Einlochen der Kugeln nur so nach Gefühl zielt, bei dem variiert das Ergebnis unvorhersehbar zwischen genial und mega peinlich. Deswegen nahm ich mir letztens vor, endlich mal zu lernen, unter welchem Winkel man die Kugeln anspielen muss, damit sie in der richtigen Richtung loslegen und schnurstracks aufs anvisierte Loch zulaufen. Dazu kaufte ich mir das Buch "Poolology" [2], das die Strategie genau erklärt. Aber leider habe ich zuhause keinen Billiardtisch zum Üben, und so dachte ich mir, wie schwer wäre es wohl, eine grafische Simulation der Billiardkugeln in Go zu schreiben, um die Technik erstmal am Bildschirm auszuprobieren bevor ich die größten Pool-Hustler in den verruchtesten Billiardspelunken San Franciscos herausfordere?

Im einfachsten Fall stoßen Billiardspieler die Kugeln gerade an, dabei trifft die weiße Kugel die angespielte farbige (hoffentlich) voll in der Mitte. Diese nimmt die Energie der weißen auf und setzt sich geradewegs in der gleichen Richtung in Bewegung, während die weiße liegen bleibt, vorausgesetzt, Tricks wie Effet bleiben mal außen vor. Komplizierter wird's, wenn eine farbige Kugel nur teilweise getroffen wird, dann bricht sie seitlich weg, während die weiße auf einem abgelenkten Pfad weiterrollt. Die dabei eingeschlagenen Winkel richten sich danach, mit welchem Überlappungsfaktor die farbige Kugel getroffen wurde. Ganz konkret, zielt der Billiardspieler nicht ins Zentrum der angespielten Kugel, sondern genau auf den Rand, liegt ein sogenannter Half-Ball vor, und die farbige Kugel driftet in einem Winkel von ungefähr 30 Grad ab (Abbildung 1).

Abbildung 1: Wer auf den Rand zielt, lenkt die Kugel um etwa 30 Grad ab.

Abbildung 2: Für 45 Grad liegt der Visierpunkt etwa 1/4 außerhalb der zu spielenden Kugel

Wer einen noch schärferen Winkel braucht, um die Kugel in ein Loch zu bugsieren, zielt nicht auf deren Rand, sondern ein Kugelviertel weiter zur Seite. Der weiße Spielball und die angespielte Kugel überlappen sich damit an einer Stelle, die einem Viertel des Kugeldurchmessers entspricht (Abbildung 2). Mit dieser Technik driftet die angespielte Kugel in einem Winkel von ungefähr 45 Grad ab (Abbildung 2).

Ohne Beule

Wie berechnen sich nun die Winkel, in dem die Billiardkugeln nach dem Zusammenprall auseinanderstieben und wie lässt sich das Spiel damit am Bildschirm simulieren? Die Billiardkugeln gehorchen den Gesetzen des sogenannten elastischen Stoßes, einem gut erforschten physikalischen Phänomen. Man sollte es nicht für möglich halten, aber hölzerne Billiardkugeln mit ihrer dünnen Lackschicht geben tatsächlich geringfügig nach, wenn sie aufeinander prallen. Kurz darauf springen sie wieder in die ursprüngliche Kugelform zurück. Der Stoß verläuft zu fast 100% elastisch, es geht also fast keine Energie verloren. Beim Zusammenstoß zweier Autos wäre das Gegenteil der Fall, die Knautschzonen beider Fahrzeuge zerknittern und verbleiben permananent in der verbeulten Form. Die ursprüngliche kinetische Energie verpufft so zu einem erheblichen Teil, und die ineinander verschlungenen Fahrzeuge trudeln mit der Restenergie noch etwas weiter.

Impuls und Energie

Bei jeder Art von mechanischer Interaktion zwischen zwei Körpern der makroskopischen Welt gilt der Impulssatz des ollen Isaac Newton, das heißt die Summe der Produkte aus den beteiligten Massen und deren Geschwindigkeiten bleiben vor und nach dem Stoß konstant. Ist der Stoß außerdem noch elastisch, gilt auch noch die Energieerhaltung, denn die kinetische Energie beider Kugeln ist vorher und nachher die gleiche. Letztere errechnet sich pro Kugel aus dem Produkt aus deren halber Masse und dem Quadrat ihrer Geschwindigkeit. Mit einem Modikum an Algebra stellt sich dann heraus, dass sich bei einem geraden Stoß die Geschwindigkeiten der beiden kollidierenden Körper austauschen und umkehren. Die weiße Kugel eilt mit der ursprünglichen Geschwindigkeit der farbigen rückwärts, die farbige mit der der weißen. Falls die farbige am Anfang stillstand, nimmt sie die Energie der weißen auf und setzt sich in der Richtung der weißen in Bewegung, während die weiße liegen bleibt.

Trifft die weiße Kugel nicht zentral auf die farbige, sondern versetzt ihr einen seitlichen Stoß, gelten die Regeln weiterhin, allerdings nur für die Komponenten der Geschwindigkeiten, die entlang einer Tangente laufen, die die Mittelpunkte beider Kugeln verbindet. Die Komponenten orthogonal zu dieser Hauptrichtung bleiben nach dem Stoß konstant. Mit etwas Trigonometrie und Algebra kommen durch Umformung dann Formeln dafür heraus, unter welchem Winkel und mit welcher Geschwindigkeit sich eine angespielte Kugel weiterbewegt, wenn der weiße Spielball sie unter einem bestimmten Winkel und einer vorgegebenen Geschwindigkeit trifft. Auf Youtube [3] erklärt der Programmierer "danielstuts" mit ausgeprägtem russischem Akzent, wie man die Stoßparameter für Videospiele berechnet (Abbildung 3). Auch unterschiedliche Massen der Kugeln würden beim Stoß eine Rolle spielen, da aber beim Billiard alle Kugeln gleich schwer sind, fallen sie aus der Formel heraus.

Abbildung 3: Youtuber erklärt das Verfahren zum elastischen Stoß im Videospiel

In Code gegossen

Die Abbildungen 4 und 5 zeigen das fertige in Go implementierte Desktopspiel in Aktion. Mit dem Fyne-Framework ist es nicht weiter schwierig, grafische Elemente wie Rechtecke oder Kreise auf die Leinwand eines Fensters zu malen und sie zu animieren. Um den Ball zu spielen, klickt der User mit der Maus entweder in die Mitte der roten Kugel für einen zentralen Stoß, oder entsprechend seitlich für einen angeschnittenen. In diese Richtung setzt sich die weiße Kugel dann in Bewegung.

Trifft die weiße Kugel auf die rote, gibt sie ihr einen Stoß entsprechend der physikalischen Berechnungen mit, und schickt sie auf die Reise, hoffentlich in die anvisierte Ecke, in der sich auf einem richtigen Poolbilliardtisch Taschen oder Löcher befinden, in die die Kugel hineinsaust. Im Demo-Spiel ist dies aus Platzgründen nicht implementiert, es wäre aber nicht weiter schwierig.

Abbildung 4: Das fertig kompilierte Go-Programm vor dem Anstoß

Abbildung 5: Die weiße Kugel hat die rote von links unten angestoßen.

Listing 1: pool.go

    01 package main
    02 import (
    03   "fyne.io/fyne/v2"
    04   "fyne.io/fyne/v2/app"
    05   "fyne.io/fyne/v2/container"
    06   col "golang.org/x/image/colornames"
    07   "github.com/quartercastle/vector"
    08   "os"
    09   "time"
    10 )
    11 func main() {
    12   a := app.New()
    13   w := a.NewWindow("Pool Billiard")
    14   width := float32(650)
    15   height := float32(700)
    16   w.Resize(fyne.NewSize(width, height))
    17   w.SetFixedSize(true)
    18   radius := float32(30)
    19   cue := drawCircle(col.White, 200, 200, radius)
    20   obj := drawCircle(col.Red, 400, 200, radius)
    21   cueBall := Ball{Ava: cue, Velo: vector.Vector{0, 0}}
    22   objBall := Ball{Ava: obj, Velo: vector.Vector{0, 0}}
    23   play := NewTapRect(width, height, func(pos fyne.Position) {
    24     shootBall(&cueBall, pos)
    25   })
    26   play.Resize(fyne.NewSize(width, height))
    27   objs := []fyne.CanvasObject{play, cue, obj}
    28   con := container.NewWithoutLayout(objs...)
    29   w.SetContent(con)
    30   w.Canvas().SetOnTypedKey(
    31     func(ev *fyne.KeyEvent) {
    32       key := string(ev.Name)
    33       switch key {
    34       case "Q":
    35         os.Exit(0)
    36       }
    37     })
    38   go func() {
    39     for {
    40       select {
    41       case <-time.After(time.Duration(5) * time.Millisecond):
    42         driveBall(&cueBall)
    43         driveBall(&objBall)
    44         slowBall(&cueBall)
    45         slowBall(&objBall)
    46         if detectCollision(&cueBall, &objBall) {
    47           collide(&cueBall, &objBall)
    48           fixOverlap(&cueBall, &objBall)
    49         }
    50         wallBounce(&cueBall, width, height, radius)
    51         wallBounce(&objBall, width, height, radius)
    52       }
    53     }
    54   }()
    55   w.ShowAndRun()
    56 }

Systemneutrale GUI

Listing 1 zeigt das Hauptprogramm des später aus allen vorgestellten Listings gepackten Binaries. Es nutzt das grafische Framework fyne, das systemneutral Desktop-Applikationen implementiert, und außer für menügesteuerte Bürosoftware auch für 2D-Spiele taugt.

Die GUI läuft in einem Applikationsfenster w, das als Spielfläche ein Rechteck play enthält. Als flitzende Billiardkugeln dienen zwei Circle-Objekte cue und obj (für cue-Ball und object ball) und der Container con ab Zeile 28 packt alle Grafikobjekte in einen Sack, den Zeile 29 dem GUI-Fenster zur Verwaltung überreicht.

Die für Videospiele typische Haupteventschleife startet ab Zeile 39 in einer parallele laufenden Goroutine, während ShowAndRun() am Ende die Verwaltung der GUI und der Nutzereingaben übernimmt, bis ein Tastendruck auf "Q" den Reigen beendet. Diesen fängt der Callback ab Zeile 30 ab und bricht mit os.Exit(0) kurzerhand das Programm ab, das daraufhin selbständig die GUI einklappt.

Frames zeichnen im Takt

Wegen des Timeouts in Zeile 41 springt die Eventschleife alle 5 Millisekunden ihren Callback an, um den nächsten Frame des Spiels zu zeichnen. Mit driveBall() bugsiert sie die bewegten Kugeln an ihre nächsten Koordinaten, bremst mit slowBall() die Bewegung um die Reibung des Tuches zu simulieren und prüft mit detectCollision() ob eine Kugel die andere berührt. In diesem Fall berechnet die Funktion collide() die neuen Bewegungsvektoren beider Kugeln nach dem Stoß und fixOverlap() bereinigt eventuell dabei auftretende Fehler, dazu später mehr. Prallt eine Kugel gegen die Bande des Tischs, findet ebenfalls eine Anpassung der Bewegung statt, wallBounce() berechnet die physikalischen Details dazu.

Aufgemotztes Rechteck

Von Haus aus bekommen primitive Canvas-Komponententen wie fyne.Rectangle im Fyne-Framework aus Effizienzgründen keine externen Ereignisse wie Mausklicks zugeteilt. Das Billiard-Spiel lebt allerdings davon, dass der User auf den Tisch tippt oder klickt und so die Kugel zum Rollen bringt. Listing 2 macht sich deshalb daran, der Rechteck-Komponente eine Widget-Hülle zu spendieren, denn Widgets laufen im Fyne-Verbund als vollwertige Mitglieder mit, die auch Mausklicks empfangen.

Listing 2: tapped-rect.go

    01 package main
    02 import (
    03   "fyne.io/fyne/v2"
    04   "fyne.io/fyne/v2/canvas"
    05   "fyne.io/fyne/v2/widget"
    06   col "golang.org/x/image/colornames"
    07 )
    08 type TapRect struct {
    09   widget.BaseWidget
    10   rect *canvas.Rectangle
    11   cb   func(fyne.Position)
    12 }
    13 func NewTapRect(width, height float32, cb func(fyne.Position)) *TapRect {
    14   tc := &TapRect{}
    15   tc.ExtendBaseWidget(tc)
    16   tc.rect = drawRectangle(col.Grey, 0, 0, width, height)
    17   tc.cb = cb
    18   return tc
    19 }
    20 func (t *TapRect) CreateRenderer() fyne.WidgetRenderer {
    21   return widget.NewSimpleRenderer(t.rect)
    22 }
    23 func (t *TapRect) Tapped(ev *fyne.PointEvent) {
    24   t.cb(ev.Position)
    25 }
    26 func (t *TapRect) TappedSecondary(_ *fyne.PointEvent) {}

Listing 2 definiert deshalb ab Zeile 8 eine Struktur TapRect, die auf dem Standard-Widget widget.BaseWidget aufbaut (also dessen Funktionen unterstützt), sowie einem Attribut rect, das das aufzumotzende Rechteck enhält. Weiter bekommt es einen Callback cb spendiert, eine Funktion, die das Frankenstein-Widget später mitteilen soll, ob der User irgendwo auf dem Rechteck mit der Maus hingeklickt oder mit dem Finger die Touchscreen berührt hat.

Wenn es rummst

Was passiert, falls zwei Kugeln aufeinanderprallen, definiert Listing 3. Ob zwei Kugeln sich gefährlich nahe kommen, prüft detectCollision() ab Zeile 16, in dem es den Abstand zwischen beiden Kreis-Objekten misst.

Grafische Frameworks wie Fyne tun sich leichter, Rechtecke herumzuschieben, anstatt Kreisflächen zu bearbeiten, und deswegen nutzt die Library Quadrate mit der Seitenlänge des Kreisradiuses und malt einen Kreis in der gewünschten Farbe hinein (Abbildung 6). Das ist nicht weiter tragisch, allerdings muss der Physikrechner des Spiels darauf achten, dass die Koordinaten der Kreisobjekte nicht dem Mittelpunkt ihrer Kreise entsprechen und dies durch einfache Addition in X- und Y-Richtung kompensieren.

Abbildung 6: Kreise in Fyne sind Rechtecke, ihre Position (x,y) bezieht sich auf die linken oberen Ecke

Die Haupteventschleife ruft detectCollision() in regelmäßigen Abständen alle paar Millisekunden auf, bevor es den aktualisierten Videoframe zeichnet. Meldet der Detektor, dass die Kugeln sich berühren, kommt die Funktion collide() ab Zeile 34 zum Zug und errechnet aus den die Geschwindigkeitsvektoren beider Kugeln deren Weitermarsch.

Listing 3: collide.go

    01 package main
    02 import (
    03   "fyne.io/fyne/v2"
    04   "fyne.io/fyne/v2/canvas"
    05   "github.com/quartercastle/vector"
    06 )
    07 type Ball struct {
    08   Ava  *canvas.Circle
    09   Velo vector.Vector
    10 }
    11 func pos2Vector(ball *Ball) vector.Vector {
    12   radius := ball.Ava.Size().Width / 2
    13   pos := ball.Ava.Position()
    14   return vector.Vector{float64(pos.X + radius), float64(pos.Y + radius)}
    15 }
    16 func detectCollision(ball1, ball2 *Ball) bool {
    17   v1 := pos2Vector(ball1)
    18   v2 := pos2Vector(ball2)
    19   dist := v1.Sub(v2).Magnitude()
    20   diameter := float64(ball1.Ava.Size().Width)
    21   return dist <= diameter
    22 }
    23 func fixOverlap(ball1, ball2 *Ball) {
    24   v1 := pos2Vector(ball1)
    25   v2 := pos2Vector(ball2)
    26   diameter := float64(ball1.Ava.Size().Width)
    27   dist := v1.Sub(v2)
    28   if dist.Magnitude() < diameter {
    29     res := dist.Unit().Scale((diameter - dist.Magnitude()))
    30     cur := ball2.Ava.Position()
    31     ball2.Ava.Move(fyne.NewPos(cur.X-float32(res[0]), cur.Y-float32(res[1])))
    32   }
    33 }
    34 func collide(ball1, ball2 *Ball) {
    35   pos1 := pos2Vector(ball1)
    36   pos2 := pos2Vector(ball2)
    37   normal := pos1.Sub(pos2).Unit()
    38   relVel := ball1.Velo.Sub(ball2.Velo)
    39   sepVel := relVel.Dot(normal)
    40   sepVelVec := normal.Scale(-sepVel)
    41   ball1.Velo = ball1.Velo.Add(sepVelVec)
    42   ball2.Velo = ball2.Velo.Add(sepVelVec.Scale(-1))
    43 }
    44 func wallBounce(ball *Ball, width, height float32, radius float32) {
    45   b := pos2Vector(ball)
    46   var axis vector.Vector
    47   if b[0] <= float64(radius) || b[0] >= float64(width-radius) {
    48     axis = vector.Vector{0, 1} // y-axis
    49   }
    50   if b[1] <= float64(radius) || b[1] >= float64(height-radius) {
    51     axis = vector.Vector{1, 0} // x-axis
    52   }
    53   if axis != nil {
    54     angle := ball.Velo.Angle(axis)
    55     ball.Velo = ball.Velo.Rotate(2*angle)
    56   }
    57   return
    58 }

Die physikalischen Berechnungen, die dafür sorgen, dass die Kugeln im Spiel realitätsgetreu herumklickern, erfordern in einem karthesischen Koordinatensystem lange Formeln mit trigonometrischen Ausdrücken. Transformiert man die Aufgaben allerdings in den Vektorraum, ist's ein Kinderspiel. Das Paket vector von Github versteckt die Berechnungen mit Sinus und Cosinus hinter den Kulissen, und im Code muss man nur Vektoren addieren, subtrahieren oder rotieren, ein wahres Hexenwerk!

Abbildung 7: Beim zweidimensionalen Stoß ändert sich nur die Komponente vt

Abbildung 7 zeigt, wie eine Kugel ball1 sich mit der Geschwindigkeit v seitlich der Kugel ball2 nähert und sie unter einem Winkel trifft. Dabei gilt es, den waagrecht laufenden Vektor für die Geschwindigkeit v in zwei Komponenten vt und vz zu unterteilen, die entlang der Tangente zwischen den beiden Kreismittelpunkten beziehungsweise orthogonal dazu verlaufen. Für den elastischen Stoß und dessen Umkehrung und Austausch der Geschwindigkeiten beider Kugeln zählt nur die Komponente vt, der Teil der im 90-Grad-Winkel zur Stoßrichtung läuft, bleibt nach dem Stoß konstant.

Die Stoßberechnung in collide() ab Zeile 34 in Listing 3 verpackt dies alles in Vektoren. In normal steht nach der Subtraktion beider Kugelpositionen ein Vektor, der vom Mittelpunkt der zweiten Kugel zur ersten zeigt, und Unit() hat seine Länge auf eins gestutzt. Den Vektor der relativen Geschwindigkeit beider Kugeln zueinander entsteht in relVel durch Subtraktion beider Geschwindigkeitsvektoren. Den Anteil dieser Geschwindigkeit in Richtung der Tangente errechnet das Skalarprodukt mit der Dot()-Funktion des Vektorpakets, allerdings als Skalarwert, den Zeile 40 mit dem Richtungsvektor normal multipliziert, damit wieder ein Vektor in Richtung der Tangente herauskommt. Fertig ist der Lack! Denn nun müssen die Zeilen 41 und 42 diesen Anteilsvektor nur noch zu den Geschwindigkeitsvektoren der Kugeln addieren, einmal in positiver und einmal in rückwärtiger Richtung, und schon stehen die neuen Geschwindigkeiten der Kugeln nach dem elastischen Stoß fest.

Nicht exakt

Leider sind numerische Verfahren auf einem Computer keine exakte Wissenschaft. Zwischen zwei Frames eines Videospiels bleibt genug Zeit, damit eine Billiardkugel unbemerkt in eine andere eindringt, ohne dass die Collision Detection Zeit fände, dies zeitnah festzustellen. Im wirklichen Leben wäre eine solche Situation natürlich undenkbar, denn die Beschränkungen der physikalischen Welt böten solchen Absurditäten Einhalt, aber im virtuellen Raum ist bekanntlich alles möglich. Das Problem an verklumpten Billiardkugeln ist nun, dass sie sich nicht von selbst lösen, weil die Physik des Spiels nicht korrekt definiert, was in diesem Fall zu tun ist. Folglich muss der Algorithmus manuell dafür sorgen, dass er die Verhältnisse der realen Welt wieder herstellt. Die Funktion fixOverlap() ab Zeile 23 richtet dies.

Abbildung 8: Wegen Ungenauigkeiten im Spiel überlappen sich Kugeln manchmal

Sie bestimmt, wie weit sich die beiden Kugeln gerade überlappen, indem es die Positionen ihrer Mittelpunkte voneinander subtrahiert. Im Vektorraum kommt bei dieser Operation ein Vektor dist heraus, der von einem Mittelpunkt zum anderen zeigt. Diesen nun staucht Unit() auf die Länge Eins zusammen und Scale() multipliziert das Ergebnis mit der Überlapp-Differenz, also kommt ein Vektor heraus, der bestimmt, wie weit und wohin die zweite (untere) Kugel wandern muss, damit beide Kugeln sich gerade noch berühren.

Wandpraller

Abbildung 9: An der Bande gilt Einfallwinkel gleich Ausfallwinkel

Prallt eine Kugel gegen die Bande des Billiardtischs, ist dies ebenfalls ein elastischer Stoß. An der Bande gilt das Konzept Einfallswinkel gleich Ausfallswinkel und die Funktion wallBounce() ab Zeile 44 nimmt die notwendige Korrektur am Lauf einer abprallenden Kugel vor. Da die Geschwindigkeit der Kugel in ihrer Ball-Struktur als Vektor vorliegt, beschreibt das Programm die Bande ebenfalls als Vektor axis und rotiert den Geschwindigkeitsvektor der Kugel entsprechend Abbildung 10 zweimal um den Einfalls-Winkel alpha. Dabei gilt es zu unterscheiden, ob die Kugel von einer vertikalen oder horizontalen Bande abprallt, vertikale Banden beschreibt der Vektor {0, 1} (x gleich 0 und y 1 nach unten) und horizontale Banden bekommen den Vektor {1, 0}.

Abbildung 10: Dreimal der gleiche Winkel wenn die Kugel von der Wand abprallt

Die Funktion slowBall() in Listing 4 sorgt dafür, dass angestoßene Kugeln nicht endlos gegen die Banden bumsen und weiterrollen, sondern dass sie in jedem Videoframe etwas Schwung verlieren, bis sie schließlich zum Stillstand kommen wie auf einem richtigen Billiardtisch auch.

Listing 4: animate.go

    01 package main
    02 import (
    03   "fyne.io/fyne/v2"
    04   "fyne.io/fyne/v2/canvas"
    05   "github.com/quartercastle/vector"
    06   "image/color"
    07 )
    08 func slowBall(ball *Ball) {
    09   ball.Velo = ball.Velo.Scale(.997)
    10 }
    11 func driveBall(ball *Ball) {
    12   if ball.Velo.Magnitude() < 0.01 {
    13     return
    14   }
    15   pos := ball.Ava.Position()
    16   v := vector.Vector{float64(pos.X), float64(pos.Y)}
    17   newpos := v.Add(ball.Velo.Scale(1.5))
    18   ball.Ava.Move(fyne.NewPos(float32(newpos[0]), float32(newpos[1])))
    19 }
    20 func shootBall(ball *Ball, to fyne.Position) {
    21   v := pos2Vector(ball)
    22   ball.Velo = vector.Vector{float64(to.X) - v[0], float64(to.Y) - v[1]}.Unit().Scale(1.5)
    23 }
    24 func drawCircle(co color.RGBA, x, y, r float32) *canvas.Circle {
    25   c := canvas.NewCircle(co)
    26   pos := fyne.NewPos(x-r, y-r)
    27   c.Move(pos)
    28   size := fyne.NewSize(2*r, 2*r)
    29   c.Resize(size)
    30   return c
    31 }
    32 func drawRectangle(co color.RGBA, x, y, w, h float32) *canvas.Rectangle {
    33   r := canvas.NewRectangle(co)
    34   r.Move(fyne.NewPos(x, y))
    35   r.Resize(fyne.NewSize(w, h))
    36   r.SetMinSize(fyne.NewSize(w, h))
    37   return r
    38 }

Listing 4 bestimmt nun noch in slowBall() ab Zeile 8, wie stark das Tuch des Tischs die Kugeln abbremst. Der Wert von 99.7% Effizienz (also 0.3% Reibung) wurde empirisch ermittelt und zeigte realitätsnahes Verhalten, könnte aber rechnerabhängig anzupassen sein.

Die Funktion driveBall() ab Zeile 11 bugsiert die Kugeln entsprechend ihrer Geschwindigkeitsvektoren über die Spielfläche, in jedem Frame geht's ein Stück weiter in die Richtung, in die der Vektor im Attribut Velo der Kugel vom Typ Ball zeigt. Da das Programm in seiner Eventschleife alle 5 Millisekunden einen neuen Frame zeichnet, ruckelt auch nichts, denn 200 Updates pro Sekunde kann kein menschliches Auge folgen.

Listing 5: build.sh

    1 $ go mod init pool
    2 $ go mod tidy
    3 $ go build pool.go collide.go tapped-rect.go animate.go

Bleibt nur noch die ganze Enchilada mit dem üblichen Dreisprung aus Listing 5 zusammenzulinken. Erst holt der Compiler die Sourcen des Fyne-Frameworks von dessen Webseite fyne.io, dann noch das Vektorpaket, kompiliert alles vor, und bindet dann noch alle vier vorgestellten Listings ein. Heraus kommt das Binary pool, das die GUI auf den Desktop zaubert. Zu einem perfekten Spiel fehlen noch die Taschen des Pool-Billiards, in die die Kugeln fallen, falls eine Collision-Detection-Funktion die Kugel genau in einer Ecke findet, sowie einige Spielkomponenten wie zufällige Startsituationen zum Üben oder Punktezähler, aber das ist trivial, wenn die Physik schon steht.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/04/snapshot/

[2]

"Poolology - Mastering the Art of Aiming", Brian Crist, https://www.amazon.com/Poolology-Mastering-Aiming-Brian-Crist/dp/1532352263

[3]

"2D Physics Engine from Scratch (JS) 08: Collision Response", https://www.youtube.com/watch?v=vnfsA2gWWOA

[4]

Zweidimensionaler elastischer Stoß, https://de.wikipedia.org/wiki/Sto%C3%9F_(Physik)#Elastischer_Sto%C3%9F

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc