Aus Linux-Magazin 07/2023

Autorennen mit Go für den Desktop

© Pavel Chagochkin / 123RF.com

Der schnellste Weg auf der Rennstrecke führt entlang der Ideallinie. Statt auf dem Nürburgring trainiert Mike Schilli seine Reflexe sicherheitshalber mit einer in Go geschriebenen Desktop-Applikation.

Vor einigen Jahren durfte ich mal bei einem Sicherheitstraining die physikalischen Grenzen meines Honda Fits ausloten. 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 von der Leine zu lassen, wohl vor allem deshalb, weil in Amerika auf den Freeways das strikte Tempolimit von meist 65 Meilen pro Stunde (104 km/h) gilt.

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 [1]. Es führt detailliert aus, 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.

Rasen lernen

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 1). Vor der gezeigten 90-Grad-Rechtskurve fährt ein Fahrer vom Schlage eines Jos Verstappen deswegen zunächst an den linken Fahrbahnrand und zieht dann scharf nach rechts zum inneren Rand. So schrammt der Rennwagen nur knapp an der Innenseite der Kurve vorbei, um kurz darauf auf der horizontalen Strecke nach der Kurve wiederum auf die linke Fahrbahnseite zu ziehen. Damit ist der Radius, den das Auto fährt, deutlich größer als der der Kurve, und es kann viel schneller durch die Kurve fahren, ohne dass die Reifen die Bodenhaftung verlieren oder das Fahrzeug ins Schleudern kommt.

Abbildung 1: Der schnellste Weg durch die Kurve nutzt den größtmöglichsten Radius.

Abbildung 1: Der schnellste Weg durch die Kurve nutzt den größtmöglichsten Radius.

Abbildung 2: Das Rennstreckenspiel in Aktion auf dem Desktop.

Abbildung 2: Das Rennstreckenspiel in Aktion auf dem Desktop.

Welt aus Geometrie

Abbildung 2 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. 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 mit Hundertsteln an.

Mit ein wenig Vorwissen aus Geometrie und Videospieltechnik klopft sich so ein simples 2D-Spiel schnell mit Go und dem Fyne-Framework [2] 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.

Am Anfang war der Kreis

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 3: Zwei konzentrische Kreise bestimmen die 90-Grad-Kurve …

Abbildung 3: Zwei konzentrische Kreise bestimmen die 90-Grad-Kurve …

Die Rechtskurve der Rennstrecke malt das Programm als Überlappung zweier konzentrischer Kreise (Abbildung 3) 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 4 die irrelevanten Kreisteile. Die blauen, grauen und orangefarbenen 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 4: … und mit einigen Rechtecken als Masken entsteht die Rennbahn.

Abbildung 4: … und mit einigen Rechtecken als Masken entsteht die Rennbahn.

Listing 1

world.go

 package main
 import (
   "fyne.io/fyne/v2"
   "fyne.io/fyne/v2/canvas"
   "fyne.io/fyne/v2/container"
   col "golang.org/x/image/colornames"
   "image/color"
 )
 func drawWorld(r1, r2 float32) (fyne.CanvasObject, Car) {
   bg := drawRectangle(col.Grey, 0, 0, 2*r2, 2*r2)
   co := drawCircle(col.Lightsalmon, r2, r2, r2)
   ci := drawCircle(col.Grey, r2, r2, r1)
   mb := drawRectangle(col.Grey, 0, r2, 2*r2, r2)
   mr := drawRectangle(col.Grey, r2, 0, r2, r2)
   in := drawRectangle(col.Lightsalmon, 0, r2, r2-r1, r2)
   out := drawRectangle(col.Lightsalmon, r2, 0, r2, r2-r1)
   car := Car{Ava: canvas.NewRectangle(col.Green),
     StartPos: fyne.NewPos(10, r2+r2-1),
   }
   car.Ava.Resize(fyne.NewSize(10, 10))
   car.Ava.Move(car.StartPos)
   objects := []fyne.CanvasObject{bg, co, ci, mb, mr, in, out, car.Ava}
   play := container.NewWithoutLayout(objects...)
   return play, car
 }
 func drawCircle(co color.RGBA, x, y, r float32) *canvas.Circle {
   c := canvas.NewCircle(co)
   pos := fyne.NewPos(x-r, y-r)
   c.Move(pos)
   size := fyne.NewSize(2*r, 2*r)
   c.Resize(size)
   return c
 }
 func drawRectangle(co color.RGBA, x, y, w, h float32) *canvas.Rectangle {
   r := canvas.NewRectangle(co)
   r.Move(fyne.NewPos(x, y))
   r.Resize(fyne.NewSize(w, h))
   return r
 }

Rundes und Eckiges

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 so weit 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.

Auto als Struktur

Listing 2 zeigt das Hauptprogramm »main«, das ein Applikationsfenster mit fester Größe aufzieht und mit »drawWorld()« aus Listing 1 die Rennstrecke samt Wagen 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 Fahrzeugs sowie die aktuelle Geschwindigkeit, die Fahrtrichtung und den Einschlagwinkel der Räder mit. In »Timer« enthält die Struktur außerdem einen Zeitmesser, der die bis dato verstrichene Zeit auf der Rennstrecke bereithält.

Listing 2

faster.go

 package main
 import (
   "fyne.io/fyne/v2"
   "fyne.io/fyne/v2/app"
   "fyne.io/fyne/v2/canvas"
   "fyne.io/fyne/v2/container"
   "fyne.io/fyne/v2/widget"
   "os"
 )
 type Car struct {
   Ava      *canvas.Rectangle
   StartPos fyne.Position
   DriveAng float32
   TurnAng  float32
   Timer    Clock
   Speed    float32
 }
 func main() {
   a := app.New()
   w := a.NewWindow("Going Faster")
   w.Resize(fyne.NewSize(650, 700))
   w.SetFixedSize(true)
   var r1, r2 float32
   r1 = 200
   r2 = 300
   play, car := drawWorld(r1, r2)
   tracker := NewTracker()
   tracker.StartPos = car.StartPos
   tracker.R1 = r1
   tracker.R2 = r2
   ctrl := animation(&car, tracker)
   quit := widget.NewButton("Quit",
     func() { os.Exit(0) })
   start := widget.NewButton("Start",
     func() {
       ctrl <- 1
     })
   car.Timer = NewClock()
   display := widget.NewLabel("")
   go func() {
     for {
       select {
       case readout := <-car.Timer.UpdateCh:
         display.SetText(readout)
         display.Refresh()
       }
     }
   }()
 car.Timer.Reset()
   car.Timer.Update()
   buttons := container.NewHBox(start, quit, display)
   con := container.NewVBox(buttons, play)
   w.SetContent(con)
   w.Canvas().SetOnTypedKey(
     func(ev *fyne.KeyEvent) {
       key := string(ev.Name)
       switch key {
       case "L":
         car.TurnAng += .001
         car.Speed -= .1
       case "H":
         car.TurnAng -= .001
         car.Speed -= .1
       case "Q":
         os.Exit(0)
       case "S":
         ctrl <- 1
       }
     })
   w.ShowAndRun()
 }

Kurven fahren

Schlägt der Fahrer des Rennwagens mithilfe des Lenkrads die Vorderräder ein, bewegt er sich auf einer Kreisbahn. Im Spiel steuern die Tasten [H]+ und [L] das virtuelle Lenkrad des Fahrzeugs ein Fitzelchen nach links oder rechts (gemäß Vi-Konvention). Die Tastatureingaben fängt der Callback zur Fyne-Funktion »SetOnTypedKey()« ab Zeile 54 ab und reagiert darauf, indem er 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 von zwei Werten ab: In welchem Winkel sich das Fahrzeug bereits bewegt, gibt »DriveAng« in der »Car«-Struktur in Zeile 13 an. Dieser Wert beschreibt in Radianten-Graden, in welcher Himmelsrichtung das Fahrzeug gerade unterwegs ist. Und wie weit das Lenkrad momentan eingeschlagen ist, steht in »TurnAng«, als Summe der durch den User initiierten 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 dieses sogenannte Ackermann-Steering zu verwenden, 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- beziehungsweise Cosinussatz aus der Schulmathematik zu errechnen (Abbildung 5). 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 herausfahren würde.

Abbildung 5: Neue Bestzeit in der Kurve mit 3,999&nbsp;Sekunden.

Abbildung 5: Neue Bestzeit in der Kurve mit 3,999 Sekunden.

Wachsames Auge

Ob das Auto noch auf der Rennstrecke fährt, bereits im Ziel steht 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 liest die nebenläufig ausgeführte Go-Routine ab Zeile 40 stetig aus und frischt die Zeit im Stoppuhr-Widget auf, das so stetig vor sich hinrattert. Die Implementierung der Stoppuhr findet sich in Listing 3.

Zeit messen

Der Channel »ctrl«, den die Funktion »animate()« erzeugt hat und in Zeile 31 von Listing 1 ans Hauptprogramm zurückgibt, bestimmt, wann der Rennwagen aus der Startposition fä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. Beide Male schiebt der Code eine “1” in den Channel, die Listing 5 später aufschnappt 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 und der Startzeit und formatiert den Wert als Sekunden und Hunderstelsekunden, um ihn so in den Channel »UpdateCh« zu schicken, wo das Hauptprogramm ihn im Label-Widget der GUI anzeigt.

Listing 3

timer.go

 package main
 import (
   "fmt"
   "time"
 )
 type Clock struct {
   Start    time.Time
   UpdateCh chan string
 }
 func NewClock() Clock {
   return Clock{
     Start:    time.Now(),
     UpdateCh: make(chan string),
   }
 }
 func (t *Clock) Reset() {
   t.Start = time.Now()
 }
 func (t Clock) Update() {
   dur := time.Since(t.Start)
   t.UpdateCh <- fmt.Sprintf("%.03f", dur.Seconds())
 }

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 der Flitzer entweder in den Büschen oder im Ziel ist und das Spiel deswegen endet.

Listing 4

tracker.go

 package main
 import (
   "fyne.io/fyne/v2"
   "math"
 )
 type Tracker struct {
   StartPos fyne.Position
   R1, R2   float32
 }
 func NewTracker() Tracker {
   tracker := Tracker{}
   return tracker
 }
 func (t Tracker) OnRoad(x, y float32) bool {
   // before curve
   if y > t.R2 && x < t.R2-t.R1 && x > 0 {
     return true
   }
   // in curve
   if y <= t.R2 && x <= t.R2 {
     h := t.R2 - y
     w := t.R2 - x
     r := float32(math.Sqrt(float64(h*h + w*w)))
     if r <= t.R2 && r >= t.R1 {
       return true
     }
     return false
   }
   // after curve
   if y <= t.R2-t.R1 && x >= t.R2 && x < 2*t.R2 && y > 0 {
     return true
   }
   return false
 }

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.

Listing 5

animate.go

 package main
 import (
   "fyne.io/fyne/v2"
   "math"
   "time"
 )
 func animation(car *Car, tracker Tracker) chan int {
   ctrl := make(chan int)
   go func() {
     for {
       select {
       case <-ctrl:
         car.TurnAng = 0
         car.DriveAng = 0
         car.Ava.Move(car.StartPos)
         car.Speed = 1
         car.Timer.Reset()
         car.Timer.Update()
         run(car, ctrl, tracker)
       }
     }
   }()
   return ctrl
 }
 func run(car *Car, ctrl chan int, tracker Tracker) {
   for {
     select {
     case <-ctrl:
     case <-time.After(time.Duration(10) * time.Millisecond):
       car.Timer.Update()
       x := car.Ava.Position().X
       y := car.Ava.Position().Y
       car.DriveAng += car.TurnAng
       car.Speed += 0.01
       x += car.Speed * float32(math.Sin(float64(car.DriveAng)))
       y -= car.Speed * float32(math.Cos(float64(car.DriveAng)))
       if tracker.OnRoad(x, y) {
         car.Ava.Move(fyne.NewPos(x, y))
       } else {
         return
       }
     }
   }
 }

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 dem Sinus- beziehungsweise Cosinussatz die nächste Koordinate als x/y-Wert, entsprechend der Geometrie in Abbildung 6.

Abbildung 6: Ge&auml;nderte x- und y-Koordinaten in Fahrtrichtung.

Abbildung 6: 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 extrem im 45-Grad-Winkel nach rechts eingeschlagen, ergäbe sich ein resultierender Winkel von Pi/4 (90-45, also 45 Grad). 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).

Listing 6

Binary erzeugen

$ go mod init faster
$ got mod tidy
$ go build

Liegt die nächste Koordinate noch auf der Strecke, was der Tracker mit »OnRoad()« in Zeile 37 feststellt, fährt der 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.

Installation

Mit allen fünf Listings in einem Verzeichnis führt der Dreisprung in Listing 6 wie immer zu einem ausführbaren Binary, das in dem 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!

Der Autor

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 mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

Infos

  1. “Going Faster”: https://books.google.de/books/about/Going_Faster.html?id=5nI-PgAACAAJ
  2. Snapshot: Mike Schilli, “Bogenlampe”, LM 12/2021, S. 76, https://www.lm-online.de/44816
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben