Rasemann und Söhne (Linux-Magazin, Juli 2023)

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.

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 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

Welt aus Geometrie

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.

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

Listing 1: world.go

    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 }

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

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

Listing 2: faster.go

    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 }

Kurven fahren

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.

Wachsames Auge

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.

Zeit messen

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.

Listing 3: timer.go

    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.

Listing 4: tracker.go

    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.

Listing 5: animate.go

    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.

Installation

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!

Infos

[1]

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

[2]

"Going Faster", Carl Lopez, Danny Sullivan, 1997, Bentley Publishers

[3]

Michael Schilli, "Bogenlampe": Linux-Magazin 12/21, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2021/12/snapshot/<U>

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