Vor einigen Jahren durfte ich mal bei einem Sicherheitstraining die physikalischen Grenzen meines Honda Fits ausloten, und kurz darauf begann ich, mich für Autorennen zu interessieren. Auch ist es unter Angestellen im Silicon Valley ein nicht unübliches Hobby, seine aufgemotzen Privatboliden auf Rennstrecken wie Laguna Seca in Kalifornien mal so richtig vom Stapel zu lassen, wohl vor allem deshalb, weil in Amerika auf den Freeways das strikte Tempolimit von meist 65 Meilen/Stunde (104 km/h) gilt.
Abbildung 1: Das Standardwerk zum Rasen auf Rennstrecken. |
Beim Studieren der Thematik nahm ich überrascht zur Kenntnis, dass es keineswegs nur darum geht, den Bleifuß immer schön auf dem Gaspedal zu lassen. Wer Rennbahnrekorde brechen will, muss exakt nach physikalischen Formeln durch die Kurven brausen und immer die Ideallinie finden, um so in jeder Runde kumulativ Sekunden einzusparen. Die physikalischen Grundlagen der Rennbahn erklärt das Standardwerk "Going Faster" von Carl Lopez, das detailliert ausführt, wie schnell man in eine Kurve fahren kann, ohne dass das Auto zu schleudern beginnt, und in welchem Winkel und zu welchem Zeitpunkt der Rennfahrer das Lenkrad einschlagen muss, damit während der Kurvenfahrt möglichst wenig Zeit verstreicht.
Dabei ist die Ideallinie durch eine Kurve nicht der kürzeste Weg, der auf der Innenseite entlang führt. Vielmehr geht es darum, auf einer Kreisbahn in einem möglichst großen Radius durch die Kurve zu fahren (Abbildung 2). Vor der gezeigten 90-Grad-Rechtskurve fährt ein Fahrer vom Schlage eines Verstappen deswegen zunächst an den linken Fahrbahnrand und zieht dann scharf nach rechts zum inneren Rand, sodass der Rennwagen nur knapp an der Innenseite der Kurve vorbeischrammt, um kurz darauf auf der horizontalen Strecke nach der Kurve wiederum auf die linke Fahrbahnseite zu ziehen. So ist der Radius, den das Auto fährt, deutlich größer als der der Kurve und der Rennwagen kann viel schneller durch die Kurve fahren, ohne dass die Reifen die Bodenhaftung verlieren oder das Fahrzeug ins Schleudern kommt und statt um die Ecke geradeaus in die Böschung fährt.
Abbildung 2: Der schnellste Weg durch die Kurve nutzt den größtmöglichsten Radius. |
Abbildung 3: Das Rennstreckenspiel in Aktion auf dem Desktop |
Abbildung 3 zeigt die Simulation einer Kurvenfahrt als in Go geschriebenes Desktop-Spiel mit Rennanimation. Der als grünes Quadrat dargestellte Rennwagen flitzt auf die Kurve zu, und der Spieler muss das Fahrzeug mit den Tasten H und L nach links und rechts steuern, damit es bei dem Höllentempo nicht am Straßenrand aneckt, sondern wohlbehalten nach der Kurvenausfahrt oben am rechten Fensterrand ankommt. Die Stoppuhr neben den beiden Schaltern läuft während der Animation und zeigt die soweit verstrichene Rundenzeit in Sekunden und Hundertsteln an.
Mit ein wenig Vorwissen aus Geometrie und Videospieltechnik klopft sich so ein simples 2D-Spiel schnell mit Go und dem Fyne-Framework zusammen. Das Programm durchläuft dazu eine Anzahl von Frames pro Sekunde, in denen es jeweils die aktuelle Lage der Spielfiguren errechnet und diese dann in der Grafik auffrischt. Gleichzeitig fängt es User-Eingaben wie Tastendrücke oder Mausklicks ab und lässt diese Ereignisse in die Berechnungen einfließen indem es zum Beispiel die Lenkung verstellt.
Wie schreibt sich so ein Spiel in Go? Zuallererst gilt es, die "Welt" des Spiels zu zeichnen und zwar so, dass das Programm später bei jedem durchlaufenen Video-Frame blitzschnell errechnen kann, ob die Spielfigur noch auf der Straße fährt oder schon die Konturen der Kurve verlassen hat und das Rennauto in den Büschen liegt.
Abbildung 4: Zwei konzentrische Kreise bestimmen die 90-Grade-Kurve ... |
Die Rechtskurve der Rennstrecke malt das Programm als Überlappung zweier konzentrischer Kreise (Abbildung 4) mit den Radien r2 (außen) und r1 (innen). Für die Kurve interessiert aber nur der linke obere Quadrant der Darstellung, also maskieren einige klug platzierte Rechtecke in Abbildung 5 die irrelevanten Kreisteile. Die blauen, grauen und orangenen Flächen verschwinden später in der Darstellung, und die beiden lachsfarbenen Rechtecke bestimmen die Einfahrt in die Kurve und deren Ausfahrt. Listing 1 stellt diese "Welt", wie es im Spielejargon heißt, mittels Circle()
und Rectangle()
-Objekten auf einem Canvas-Objekt des Fyne-Frameworks dar.
Abbildung 5: ... und mit einigen Rechtecken als Masken entsteht die Rennbahn. |
01 package main 02 import ( 03 "fyne.io/fyne/v2" 04 "fyne.io/fyne/v2/canvas" 05 "fyne.io/fyne/v2/container" 06 col "golang.org/x/image/colornames" 07 "image/color" 08 ) 09 func drawWorld(r1, r2 float32) (fyne.CanvasObject, Car) { 10 bg := drawRectangle(col.Grey, 0, 0, 2*r2, 2*r2) 11 co := drawCircle(col.Lightsalmon, r2, r2, r2) 12 ci := drawCircle(col.Grey, r2, r2, r1) 13 mb := drawRectangle(col.Grey, 0, r2, 2*r2, r2) 14 mr := drawRectangle(col.Grey, r2, 0, r2, r2) 15 in := drawRectangle(col.Lightsalmon, 0, r2, r2-r1, r2) 16 out := drawRectangle(col.Lightsalmon, r2, 0, r2, r2-r1) 17 car := Car{Ava: canvas.NewRectangle(col.Green), 18 StartPos: fyne.NewPos(10, r2+r2-1), 19 } 20 car.Ava.Resize(fyne.NewSize(10, 10)) 21 car.Ava.Move(car.StartPos) 22 objects := []fyne.CanvasObject{bg, co, ci, mb, mr, in, out, car.Ava} 23 play := container.NewWithoutLayout(objects...) 24 return play, car 25 } 26 func drawCircle(co color.RGBA, x, y, r float32) *canvas.Circle { 27 c := canvas.NewCircle(co) 28 pos := fyne.NewPos(x-r, y-r) 29 c.Move(pos) 30 size := fyne.NewSize(2*r, 2*r) 31 c.Resize(size) 32 return c 33 } 34 func drawRectangle(co color.RGBA, x, y, w, h float32) *canvas.Rectangle { 35 r := canvas.NewRectangle(co) 36 r.Move(fyne.NewPos(x, y)) 37 r.Resize(fyne.NewSize(w, h)) 38 return r 39 }
Da das Fyne-Framework etwas unhandliche Methoden zum Platzieren von Kreisen und Rechtecken hat, definieren die Funktionen drawCircle()
und drawRectangle()
ab den Zeilen 26 und 34 praktischere Schnittstellen. Die Kreisfunktion nimmt als ersten Parameter die Füllfarbe entgegen, gefolgt vom Mittelpunkt im x/y-Format und einem Radius r
. Fyne selbst platziert Circle-Objekte anhand der linken oberen Ecke eines imaginären Quadrats, das den Kreis umschließt, und bugsiert es mittels Move()
dorthin, um es anschließend mit Resize()
auf die geforderte Größe aufzublasen. Um einen Kreis mit Radius r
zu erhalten, weist das umschließende Quadrat eine Seitenlänge von 2*r auf. Die Schnittstelle für Rechtecke in Fyne ist etwas logischer, und drawRectangle()
kombiniert nur die Aufrufe der Methoden Move()
und Resize()
damit die aufrufende Funktion alles in einem Aufwasch erledigen kann.
Mit diesem Rüstzeug geht die Hauptfunktion drawWorld()
daran, die zwei konzentrischen Kreise ci
und co
, die drei maskierenden Rechtecke bg
(Hintergrund), mb
(unten) und mr
(rechts oben), sowie die beiden ein- und ausleitenden Straßenstücke in
und out
als lachsfarbene Rechtecke zu zeichnen. Das Rennauto erzeugt die Funktion als grünes Rechteck und packt die Ausmaße des Avatars in eine Struktur Car
, die noch weitere Parameter wie Geschwindigkeit und Startposition enthält und später in Listing 2 definiert wird. Alle soweit erzeugten Grafik-Objekte packt Zeile 23 in einen Container, den drawWorld()
mitsamt dem Car
-Objekt an den Aufrufer zurückgibt, auf dass dieser sie dem Grafik-Engine des Frameworks zur Verwaltung zuführe.
Listing 2 zeigt das Hauptprogramm main
, das ein Applikationsfenster mit fester Größe aufzieht und mit drawWorld()
aus Listing 1 die Rennstrecke samt Auto hineinzeichnet. Die Struktur vom Typ Car
ab Zeile 10 definiert in Ava
(wie in "Avatar") wie das Auto in der Grafik-Welt dargestellt wird, nämlich als grünes Rechteck. Weiter schleppt die Struktur noch die Anfangskoordinaten des Autos sowie die aktuelle Geschwindigkeit, die Fahrtrichtung und den Einschlagwinkel der Räder mit. In Timer
führt die Struktur außerdem einen Zeitmesser mit, der die bis dato verstrichene Zeit auf der Rennstrecke bereithält.
01 package main 02 import ( 03 "fyne.io/fyne/v2" 04 "fyne.io/fyne/v2/app" 05 "fyne.io/fyne/v2/canvas" 06 "fyne.io/fyne/v2/container" 07 "fyne.io/fyne/v2/widget" 08 "time" 09 "fmt" 10 "os" 11 ) 12 type Car struct { 13 Ava *canvas.Rectangle 14 StartPos fyne.Position 15 DriveAng float32 16 TurnAng float32 17 Timer Clock 18 Speed float32 19 } 20 func main() { 21 a := app.New() 22 w := a.NewWindow("Going Faster") 23 w.Resize(fyne.NewSize(650, 700)) 24 w.SetFixedSize(true) 25 var r1, r2 float32 26 r1 = 200 27 r2 = 300 28 play, car := drawWorld(r1, r2) 29 tracker := NewTracker() 30 tracker.StartPos = car.StartPos 31 tracker.R1 = r1 32 tracker.R2 = r2 33 ctrl := animation(&car, tracker) 34 quit := widget.NewButton("Quit", 35 func() { os.Exit(0) }) 36 start := widget.NewButton("Start", 37 func() { 38 ctrl <- 1 39 }) 40 car.Timer = NewClock() 41 display := widget.NewLabel("") 42 go func() { 43 for { 44 select { 45 case readout := <-car.Timer.UpdateCh: 46 display.SetText(readout) 47 display.Refresh() 48 } 49 } 50 }() 51 keyDisp := widget.NewLabel("") 52 car.Timer.Reset() 53 car.Timer.Update() 54 buttons := container.NewHBox(start, quit, display, keyDisp) 55 con := container.NewVBox(buttons, play) 56 w.SetContent(con) 57 w.Canvas().SetOnTypedKey( 58 func(ev *fyne.KeyEvent) { 59 key := string(ev.Name) 60 keyDisp.SetText(fmt.Sprintf("*KeyPress [%s]*", key)) 61 keyDisp.Refresh() 62 go func() { 63 time.Sleep(1 * time.Second) 64 keyDisp.SetText("") 65 keyDisp.Refresh() 66 }() 67 switch key { 68 case "L": 69 car.TurnAng += .001 70 car.Speed -= .1 71 case "H": 72 car.TurnAng -= .001 73 car.Speed -= .1 74 case "Q": 75 os.Exit(0) 76 case "S": 77 ctrl <- 1 78 } 79 }) 80 w.ShowAndRun() 81 }
Schlägt der Fahrer des Rennwagens mittels dessen Lenkrad die Vorderräder ein, bewegt sich das Fahrzeug auf einer Kreisbahn. Im Spiel bewegen die Tasten H und L das virtuelle Lenkrad des Wagens jeweils ein Fitzelchen nach links oder rechts (gemäß "vi"-Konvention). Die Tastatureingaben des Users fängt der Callback zur Fyne-Funktion SetOnTypedKey()
ab Zeile 54 ab und reagiert darauf, indem es den Lenkradwinkel TurnAng
sowie die Geschwindigkeit verstellt.
Das vereinfachte Modell der 2D-Simulation beschleunigt das Fahrzeug konstant, indem es später in Listing 5 pro Video-Frame 0.01 Einheiten zur Anfangsgeschwindigkeit mit dem Wert 1 addiert. Jedes Mal, wenn der Fahrer das Lenkrad verstellt, sinkt die Geschwindigkeit hingegen um 0.1 Einheiten. Jeder Lenkvorgang macht also die Beschleunigung aus den letzten 10 Frames zunichte, insofern ist es günstig, möglichst wenig hektisch zu lenken, genau wie auf einer echten Rennstrecke auch.
Wie sich das Fahrzeug anschließend im Bild weiterbewegt, hängt naturgemäß von zwei Werten ab: In welchem Winkel sich das Fahrzeug bereits bewegt, gibt DriveAng
in der Car
-Struktur in Zeile 13 an. Dieser Wert gibt in Radianten-Graden an, in welcher Himmelsrichtung das Fahrzeug gerade unterwegs ist. Und wie weit das Lenkrad momentan eingeschlagen ist, steht in TurnAng
, als Summe der seitens des Users initierten Lenkradbewegungen. Die Summe beider Werte bestimmt anschließend in der Animation von Listing 5 die neue Richtung des Fahrzeugs.
Eine absolut realistische Simulation müsste hier allerdings die in Autos verbauten Vorder- und Hinterachsen einkalkulieren, von denen sich (normalerweise) nur die Räder der Vorderachse verstellen lassen. Statt diesem sogenannten Ackermann-Steering begnügt sich das einfache Spiel aber damit, die beiden Winkel der Fahrtrichtung und der Lenkradeinstellung zu addieren und die nächste im Spielparcours angefahrene Koordinate später mit dem Sinus- bzw. Cosinussatz aus der Schulmathematik zu errechnen (Abbildung 6). Deshalb stellt das Spiel das Auto auch als kleines Quadrat dar, denn ein realistischeres Rechteck sähe komisch aus, wenn es unkorrigiert seitwärts aus der Kurve ausfahren würde.
Abbildung 6: Neue Bestzeit in der Kurve mit 3.999 Sekunden. |
Ob das Auto noch auf der Rennstrecke fährt oder bereits im Ziel ist wie in Abbildung 6, oder vielleicht auf halber Strecke von der Fahrbahn abgekommen ist, bestimmt das Objekt vom Typ Tracker
ab Zeile 27 in Listing 2. Es kennt die Ausmaße des Parcours und kann später in animation()
in Listing 5 blitzschnell errechnen, ob die gegenwärtige Koordinate noch auf der Straße liegt oder daneben. Die Implementierung dieser geometrischen Funktionen findet sich in Listing 4.
Ein Objekt vom Typ Clock
misst die aktuell verstrichene Rundenzeit ab Zeile 38 in Listing 2 und zeigt sie in Sekunden und Hundersteln in einem Fyne-Widget vom Typ Label
an. Die GUI erhält bei jedem durchlaufenen Frame des Videospiels die aktuell anzuzeigende Zeit über den Channel UpdateCh
, den die nebenläufig ausgeführte Go-Routine ab Zeile 40 stetig ausliest und die Zeit im Stoppuhr-Widget auffrischt, das so stetig vor sich hinrattert. Die Implementierung der Stoppuhr findet sich in Listing 3.
Der Channel ctrl
, den die Funktion animate()
erzeugt hat und in Zeile 31 von Listing 1 ans Hauptprogramm zurückgibt, bestimmt, wann das Auto aus der Startposition ausfährt und beschleunigt. Dies geschieht entweder in Zeile 36 als Reaktion auf das Klicken des "Start"-Buttons mit der Maus, oder auf das Drücken der Taste "S" in Zeile 67. Beides Mal schiebt der Code eine "1" in den Channel, die Listing 5 später aufschnappen und das Auto anschieben wird.
Listing 3 definiert den Zeitmesser, dessen Reset()
-Funktion die verstrichene Zeit auf der Rennstrecke auf Null setzt, indem sie in Start
die aktuelle Uhrzeit ablegt. Bei jedem Aufruf der Funktion Update()
ab Zeile 19 bestimmt Since()
die Differenz aus aktueller Uhrzeit minus der Startzeit und formatiert den Wert als Sekunden und Hunderstel, um ihn so in den Channel UpdateCh
zu schicken, wo das Hauptprogramm ihn aufschnappt und im Label-Widget der GUI anzeigt.
01 package main 02 import ( 03 "fmt" 04 "time" 05 ) 06 type Clock struct { 07 Start time.Time 08 UpdateCh chan string 09 } 10 func NewClock() Clock { 11 return Clock{ 12 Start: time.Now(), 13 UpdateCh: make(chan string), 14 } 15 } 16 func (t *Clock) Reset() { 17 t.Start = time.Now() 18 } 19 func (t Clock) Update() { 20 dur := time.Since(t.Start) 21 t.UpdateCh <- fmt.Sprintf("%.03f", dur.Seconds()) 22 }
Ob das Auto noch auf der Strecke fährt oder davon abgekommen ist, bestimmt Listing 4 mit dem Tracker
-Objekt. In seinem Konstruktor speichert es die Radien R1 und R2 der 90-Grad-Kurve des Spiels und bestimmt daraus die Koordinaten des gültigen Spielraums. Die Funktion OnRoad()
ab Zeile 14 berechnet zu einer vorgegebenen x/y-Koordinate, ob diese auf der Fahrbahn liegt oder daneben. Dazu teilt es den Parcours in drei Bereiche auf: Vor der Kurve, in der Kurve, und die Ausfahrt ins Ziel. Gibt OnRoad()
einen wahren Wert zurück, ist der Wagen noch auf der Strecke, während ein falscher Wert signalisiert, dass das Auto entweder in den Büschen oder im Ziel ist und das Spiel deswegen endet.
01 package main 02 import ( 03 "fyne.io/fyne/v2" 04 "math" 05 ) 06 type Tracker struct { 07 StartPos fyne.Position 08 R1, R2 float32 09 } 10 func NewTracker() Tracker { 11 tracker := Tracker{} 12 return tracker 13 } 14 func (t Tracker) OnRoad(x, y float32) bool { 15 // before curve 16 if y > t.R2 && x < t.R2-t.R1 && x > 0 { 17 return true 18 } 19 // in curve 20 if y <= t.R2 && x <= t.R2 { 21 h := t.R2 - y 22 w := t.R2 - x 23 r := float32(math.Sqrt(float64(h*h + w*w))) 24 if r <= t.R2 && r >= t.R1 { 25 return true 26 } 27 return false 28 } 29 // after curve 30 if y <= t.R2-t.R1 && x >= t.R2 && x < 2*t.R2 && y > 0 { 31 return true 32 } 33 return false 34 }
Listing 5 schließlich steuert die Dynamik des Spielablaufs, vom Start des Reigens ab Zeile 13, nachdem das Kommando dazu auf dem Steuerkanal ctrl
angekommen ist, bis zum Update in jedem einzelnen Spielframe, von denen 100 pro Sekunde durchrauschen.
01 package main 02 import ( 03 "fyne.io/fyne/v2" 04 "math" 05 "time" 06 ) 07 func animation(car *Car, tracker Tracker) chan int { 08 ctrl := make(chan int) 09 go func() { 10 for { 11 select { 12 case <-ctrl: 13 car.TurnAng = 0 14 car.DriveAng = 0 15 car.Ava.Move(car.StartPos) 16 car.Speed = 1 17 car.Timer.Reset() 18 car.Timer.Update() 19 run(car, ctrl, tracker) 20 } 21 } 22 }() 23 return ctrl 24 } 25 func run(car *Car, ctrl chan int, tracker Tracker) { 26 for { 27 select { 28 case <-ctrl: 29 case <-time.After(time.Duration(10) * time.Millisecond): 30 car.Timer.Update() 31 x := car.Ava.Position().X 32 y := car.Ava.Position().Y 33 car.DriveAng += car.TurnAng 34 car.Speed += 0.01 35 x += car.Speed * float32(math.Sin(float64(car.DriveAng))) 36 y -= car.Speed * float32(math.Cos(float64(car.DriveAng))) 37 if tracker.OnRoad(x, y) { 38 car.Ava.Move(fyne.NewPos(x, y)) 39 } else { 40 return 41 } 42 } 43 } 44 }
Den Ablauf dieser Frames steuert der Timer in Zeile 29 in Listing 5, der genau 10 Millisekunden wartet, bevor der Code ab Zeile 30 zu laufen beginnt. Dieser frischt die Zeitanzeige auf und liest die aktuelle Position des Rennwagens als x
und y
aus. Zeile 34 erhöht mit jedem durchlaufenen Frame die Geschwindigkeit Speed
um 0.01 (von einem Anfangswert von 1) und errechnet in den Zeilen 35 und 36 mit Sinus- bzw. Cosinussatz die nächste Koordinate als x/y-Wert, entsprechend der Geometrie in Abbildung 7.
Abbildung 7: Geänderte X- und Y-Koordinaten in Fahrtrichtung |
Fährt das Fahrzeug zum Beispiel Richtung Norden, also im Winkel 90 Grad oder Pi/2 in der Radianten-Darstellung, und die Vorderräder wären extremst im 45-Gradwinkel nach rechts eingeschlagen, ergäbe sich ein resultierender Winkel von Pi/4 (90 - 45, also 45 Grad) und aus der aktuellen Spielkoordinate (x, y) würde sich das Fahrzeug im nächsten Taktschlag des Spiels nach rechts oben bewegen, also in die Koordinate (x+1, y-1) einfahren (y-Koordinaten zählt die UI von oben nach unten, also fallen die y-Werte in Richtung Norden).
Liegt die nächste Koordinate noch auf der Strecke, was der Tracker mit OnRoad()
in Zeile 37 feststellt, fährt das Auto-Avatar in Zeile 38 mit Move()
dorthin. Falls nicht, ist das Auto im Ziel oder liegt im Graben und return
in Zeile 40 beendet die sonst endlos weiterlaufende for
-Schleife. Fertig ist das Spiel.
Mit allen 5 Listings in einem Verzeichnis führt der Dreisprung
$ go mod init faster
$ got mod tidy
$ go build
wie immer zu einem ausführbaren Binary, das in diesem Fall faster
heißt. Es ist gar nicht so einfach, den Wagen nach dem Start und der anfänglichen Beschleunigung mit den Tasten H und L auf der Strecke zu halten und ohne anzuschrammen durch die Kurve zu fahren. Aber Übung macht den Meister, und dann geht es ans Brechen alter Rundenrekorde!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2023/06/snapshot/
"Going Faster", Carl Lopez, Danny Sullivan, 1997, Bentley Publishers
Michael Schilli, "Bogenlampe": Linux-Magazin 12/21, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2021/12/snapshot/<U>
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc