Aus Linux-Magazin 07/2022

Geografieratespiel in Go

Das Geografie-Wordle bewertet Rateversuche anhand der Entfernung und Himmelsrichtung zum gesuchten Ort. Mike Schilli macht daraus ein Ratespiel in Go, mit Fotos aus seiner Privatsammlung.

Nach dem durchschlagenden Erfolg des Wortratespiels Wordle [1] ließen die Nachahmer nicht lange auf sich warten. Zu den besten Trittbrettfahrern zählt das unterhaltsame Geografiespiel Worldle [2], bei es ein Land auf dem Erdball zu erraten gilt. Bei jedem Rateversuch hilft der Server dem Spieler mit der Information weiter, wie weit der geratene Ort vom Ziel entfernt liegt und in welcher Himmelsrichtung sich das gesuchte Land befindet.

Der Umrisse des gesuchten Staats unkundig, tippt der Spieler in Abbildung 1 zunächst auf das Fürstentum Liechtenstein. Der Worldle-Server weist prompt darauf hin, dass der gesuchte Ort 5371 Kilometer von diesem Zwergstaat entfernt liegt, und zwar in östlicher Richtung (Pfeil nach rechts). Der zweite Rateversuch tippt auf Belarus, doch Weißrussland liegt laut Worldle-Server 4203 Kilometer südwestlich vom Ziel. Die Mongolei (Mongolia auf Englisch) als dritter Versuch schießt übers Ziel hinaus, denn von dort sind es noch 3476 Kilometer in südwestlicher Richtung zum gesuchten Land.

Abbildung 1: Das originale Geografie-Ratespiel Worldle.

Abbildung 1: Das originale Geografie-Ratespiel Worldle.

Damit geht dem Spieler langsam ein Licht auf, und er gelangt zu der Erkenntnis, dass sich das gesuchte Land im Großraum Indien befindet. Daraufhin errät er im vierten Versuch das gesuchte Ziel Pakistan. Ein Riesenspaß, und jeden Tag kommt ein neues Land dran.

Privatfotos enthüllt

Statt Länder der Erde zu erraten, dachte ich mir nun, dass es ganz amüsant wäre, eine Handvoll Fotos aus meiner riesigen, über Jahre wild gewachsenen Handyfoto-Privatsammlung zufällig auszuwählen und darauf zu achten, dass ihre GPS-Daten sie als relativ weit voneinander entfernt liegend ausweisen. Anfangs wählt der Computer ein zufälliges Foto aus dem Set als Lösung aus. Er hält diese aber geheim und tischt dem Spieler eine ebenfalls zufällig ausgewählte Aufnahme auf, samt der Information, wie viele Kilometer zwischen deren Aufnahmeort und jenem des Bilds liegen und in welcher Himmelsrichtung der Spieler sich dazu auf dem Globus bewegen muss.

Mit dieser Information gewappnet, ist nun zu erraten, bei welchem Bild aus der verbleibenden Auswahl es sich wohl um das gesuchte handelt. Er klickt es mit der Maus an und bekommt die Bewertung seines Rateversuchs wieder mit Kilometerangabe und Himmelsrichtung mitgeteilt. Schafft der Spieler es, sich auf diese Weise gedanklich in möglichst wenigen Zügen zur Lösung vorzuarbeiten, gewinnt er das Spiel. Eine Art Schnitzeljagd also, und dem Wordle-Thema folgend heißt das Programm Schnitzle. Fotos zur Auswahl gibt es auf dem Handy genug, und ein Zufallsgenerator sorgt dafür, dass das Spiel immer neue Bilder auswählt, sodass es nie langweilig wird.

Action!

Abbildung 2 zeigt das Spiel in Aktion. Der Computer hat als Startbild ein Bergfoto vom Aggenstein in den bayrischen Alpen ausgewählt. Laut Hinweis liegt dieser Ort 9457 Kilometer vom Ziel entfernt, das sich nordwestlich davon befindet (NW). Es geht also offensichtlich um Nordamerika. Aus der Auswahl rechts klickt der User dann auf ein Foto aus dem Pinnacle-Nationalpark in Kalifornien (Abbildung 3). Schnitzle zeigt an, dass es von dort noch 168,5 Kilometer bis zum gesuchte Ziel sind, wiederum nach Nordwesten. Nördlich von den Pinnacles – hmm, das muss in der San Francisco Bay Area liegen.

Abbildung 2: Ausgangsposition: Schnitzle wählt ein Foto 9458 Kilometer vom Zielort entfernt.

Abbildung 2: Ausgangsposition: Schnitzle wählt ein Foto 9458 Kilometer vom Zielort entfernt.

Abbildung 3: Der Spieler klickt auf ein Foto aus dem Pinnacle-Nationalpark, noch 168 Kilometer von Ziel entfernt.

Abbildung 3: Der Spieler klickt auf ein Foto aus dem Pinnacle-Nationalpark, noch 168 Kilometer von Ziel entfernt.

Also klickt der Spieler in Abbildung 4 auf das Foto vom Parkplatz am Strand in Pacifica, wo ich oft surfen gehe. Aber von dort sind es immer noch 10 Kilometer bis zum Aufnahmeort des gesuchten Fotos, nach Nordosten (NE). Wer die Gegend kennt, dem schwant es schon: Das Ziel muss im Vorort South San Francisco liegen, wo sich der Riesensupermarkt Costco befindet. Tatsächlich ist dessen Grillstation mit den aufgereihten Brathendln die Lösung, wie die Meldung *** WINNER *** signalisiert (Abbildung 5). Ungeklickt verbleiben die beiden Fotos in der rechten Spalte, die eine Brücke in Heidelberg und den Sand eines Pazifikstrands in der Bay Area zeigen.

Abbildung 4: Vom Parkplatz am Strand von Pacifica sind es noch 10 Kilometer zum Ziel.

Abbildung 4: Vom Parkplatz am Strand von Pacifica sind es noch 10 Kilometer zum Ziel.

Abbildung 5: Die Lösung: die Grillstation im Costco-Supermarkt in South San Francisco.

Abbildung 5: Die Lösung: die Grillstation im Costco-Supermarkt in South San Francisco.

Wer suchet, der findet

Wie funktioniert das Spiel nun als Go-Programm? Eine vom Handy auf die Festplatte heruntergeladene Fotosammlung vollständig zu durchforsten, nimmt einige Zeit in Anspruch, selbst wenn sie auf einer schnellen SSD-Platte liegt. Deshalb durchwandert das Helferprogramm »finder.go« aus Listing 1 die Untiefen eines in Zeile 18 eingestellten Handyfoto-Verzeichnisses, analysiert dort jedes gefundene JPEG-Bild und liest dessen GPS-Daten aus, falls vorhanden.

Die Ergebnisse füttert es in eine Tabelle einer SQLite-Datenbank, sodass das Spielprogramm später in jeder Runde schnell neue Bilder auswählen kann, ohne zeitraubend ganze Dateibäume zu durchforsten. Eine leere SQLite-Datenbank mit der geforderten Tabelle, die Dateinamen GPS-Daten zuordnet, erzeugen Sie im Nu mit einem Shell-Befehl wie dem aus Abbildung 6.

Abbildung 6: Eine leere Fotodatenbank, erzeugt mit dem Sqlite3-Client.

Abbildung 6: Eine leere Fotodatenbank, erzeugt mit dem Sqlite3-Client.

Nun darf das mit »go build finder.go« kompilierte Programm aus Listing 1 loslegen. Es greift auf die beiden Bibliotheken go-sqlite3 und goexif2 von Github zurück. Die eine dient zum Ansteuern der Flatfile-Datenbank, die andere zum Auslesen der GPS-Header aus den JPEG-Fotos.

Damit der Go-Compiler das ohne Murren erledigt, legen Sie mit »go mod init finder; go mod tidy« zuerst ein Go-Modul fest, das die im Quellcode eingebundenen Bibliotheken analysiert, sie bei Bedarf von Github holt und deren Versionen festnagelt. Erst dann produziert der »build«-Befehl ein statisches Binary »finder«, das alle Bibliotheken kompiliert mit sich führt.

Auf der Kommandozeile aufgerufen, las Finder die etwa 4000 Dateien in meinem Handy-Folder in etwa 30 Sekunden ein und legte die Metadaten der Fotos in der SQLite-Datei »photos.db« in der Tabelle »files« ab (Abbildung 7).

Abbildung 7: Nach dem Finder-Lauf befinden sich 4162 Bilder nebst GPS-Koordinaten in der Datenbank.

Abbildung 7: Nach dem Finder-Lauf befinden sich 4162 Bilder nebst GPS-Koordinaten in der Datenbank.

Listing 1

finder.go

package main
import (
  "database/sql"
  "fmt"
  _ "github.com/mattn/go-sqlite3"
  exif "github.com/xor-gate/goexif2/exif"
  "os"
  "path/filepath"
  rex "regexp"
)
type Walker struct {
  Db *sql.DB
}
func main() {
  searchPath := "photos"
  db, err := sql.Open("sqlite3", "photos.db")
  w := &Walker{ Db: db }
  err = filepath.Walk(searchPath, w.Visit)
  panicOnErr(err)
  db.Close()
}
func (w *Walker) Visit(path string,
  f os.FileInfo, err error) error {
  jpgMatch := rex.MustCompile("(?i)JPG$")
  match := jpgMatch.MatchString(path)
  if !match {
    return nil
  }
  lat, long, err := GeoPos(path)
  panicOnErr(err)
  stmt, err := w.Db.Prepare("INSERT INTO files VALUES(?,?,?)")
  panicOnErr(err)
  fmt.Printf("File: %s %.2f/%.2f\n", path, lat, long)
  _, err = stmt.Exec(path, lat, long)
  panicOnErr(err)
  return nil
}
func GeoPos(path string) (float64,
  float64, error) {
  f, err := os.Open(path)
  if err != nil {
    return 0, 0, err
  }
  x, err := exif.Decode(f)
  if err != nil {
    return 0, 0, err
  }
  lat, long, err := x.LatLong()
  if err != nil {
    return 0, 0, err
  }
  return lat, long, nil
}

Der Aufruf der Funktion »Walk« in Zeile 22 von Listing 1 nimmt den ab Zeile 28 definierten Callback »w.Visit« mit, den der Stöberer bei jeder gefundenen Datei aufruft. Die Datenstruktur vom Typ »Walker« schleppt er dabei immer als sogenannten Receiver mit und erhält so gleich Zugriff auf das Handle »db« der vorher geöffneten SQLite-Datenbank.

Zeile 31 prüft für jede gefundene Datei, ob sie auch die Endung ».jpg« (groß oder klein) trägt, und liest dann über die Funktion »GeoPos()« ab Zeile 47 die EXIF-Daten des Fotos ein. Darin finden sich im Idealfall auch die geografische Länge und Breite des Aufnahmeorts als Fließkommazahl.

Zeile 39 speist mit einem SQL-typischen »INSERT«-Statement Pfad und GPS-Daten in die Datenbanktabelle ein. Von dort kann das Hauptprogramm »schnitzle« sie später flugs abholen, wenn es neues Bildmaterial für ein neues Spiel sucht.

Qual der Wahl

Es fällt nicht besonders schwer, ein Dutzend Bilder zufällig aus einer Fotosammlung von mehreren Tausend Bildern auszuwählen. Schon kniffliger ist es sicherzustellen, dass die in einer Spielrunde abgebildeten Orte nicht allzu nah zusammenliegen. Viele Handyfotos entstehen zu Hause, und der Spielspaß beim meterweisen Navigieren zwischen Wohnzimmer, Balkon und Küche hält sich in Grenzen.

Stattdessen sollte der Algorithmus Bilder zwar zufällig aussuchen, damit stets neue Spielsituationen entstehen, dabei aber immer eine gute Mischung aus unterschiedlichen Regionen präsentieren. Die geografische Ansicht der Foto-App auf dem Handy in Abbildung 8 illustriert, wie sich die Aufnahmen anhand der GPS-Daten gebündelt Hotspots zuweisen lassen, aus denen der Algorithmus jeweils nur ein Bild wählt.

Abbildung 8: Das Geo-Clustering von Fotos auf dem Handy.

Abbildung 8: Das Geo-Clustering von Fotos auf dem Handy.

Hier hilft der k-Means-Algorithmus [3] weiter, ein Verfahren aus der künstlichen Intelligenz [4], das dort zur Cluster-Bildung (Abbildung 9) beim unüberwachten Lernen [5] dient. Aus einer Menge mehr oder weniger zufällig verteilter Punkte im zwei- oder mehrdimensionalen Raum bestimmt k-Means die Mittelpunkte von Anhäufungen. Im Schnitzle-Spiel wären das Orte, an denen viele Handyfotos entstehen, etwa daheim oder an verschiedenen Urlaubszielen. Aus diesen Clustern wählt der Algorithmus dann jeweils zufällig nur ein Bild aus. Das stellt sicher, dass zwischen den Aufnahmeorten der einzelnen Bilder ordentliche Wegstrecken zurückzulegen sind.

Abbildung 9: Die Bibliothek <span class="ui-element">kmeans</span> f&uuml;r Go auf Github.

Abbildung 9: Die Bibliothek kmeans für Go auf Github.

Listing 2

photoset.go

package main
import (
  "database/sql"
  "fmt"
  _ "github.com/mattn/go-sqlite3"
  "github.com/muesli/clusters"
  "github.com/muesli/kmeans"
  "math/rand"
)
type Photo struct {
  Path string
  Lat  float64
  Lng  float64
}
func photoSet() ([]Photo, error) {
  db, err := sql.Open("sqlite3", "photos.db")
  panicOnErr(err)
  photos := []Photo{}
  query := fmt.Sprintf("SELECT path, lat, long FROM files")
  stmt, _ := db.Prepare(query)
  rows, err := stmt.Query()
  panicOnErr(err)
  var d clusters.Observations
  lookup := map[string]Photo{}
  keyfmt := func(lat, lng float64) string {
    return fmt.Sprintf("%f-%f", lat, lng)
  }
  for rows.Next() {
    var path string
    var lat, lng float64
    err = rows.Scan(&path, &lat, &lng)
    panicOnErr(err)
    lookup[keyfmt(lat, lng)] = Photo{Path: path, Lat: lat, Lng: lng}
    d = append(d, clusters.Coordinates{
      lat,
      lng,
    })
  }
  db.Close()
  maxClusters := 6
  km := kmeans.New()
  clusters, err := km.Partition(d, 10)
  panicOnErr(err)
  rand.Shuffle(len(clusters), func(i, j int) {
    clusters[i], clusters[j] = clusters[j], clusters[i]
  })
  for _, c := range clusters {
    if len(c.Observations) < 3 {
      continue
    }
    rndIdx := rand.Intn(len(c.Observations))
    coords := c.Observations[rndIdx].Coordinates()
    key := keyfmt(coords[0], coords[1])
    photo := lookup[key]
    photos = append(photos, photo)
    if len(photos) == maxClusters {
      break
    }
  }
  return photos, nil
}
func randPickExcept(pick []Photo, notIdx int) int {
  idx := rand.Intn(len(pick)-1) + 1
  if idx == notIdx {
    idx = 0
  }
  return idx
}

Die Funktion »photoSet()« ab Zeile 18 von Listing 2 hat die Aufgabe, ein Array-Slice von sechs Fotos des Typs »Photo« für ein neues Spiel zu liefern. Zeile 12 definiert die Datenstruktur »Photo«. Sie enthält zum einen die Komponente »path« für den Dateipfad zur Bilddatei. Zum anderen konserviert sie die aus den EXIF-Informationen ausgelesenen Geokoordinaten als Länge »Lng« und Breite »Lat«, jeweils in Form einer 64-Bit-Fließkommazahl.

Dazu dockt »photoSet()« ab Zeile 19 an der vorher angelegten SQLite-Datenbank »photos.db« an und feuert den »SELECT«-Query ab Zeile 23 ab, um alle vorher eingelesenen Fotodateien mit ihren GPS-Koordinaten durchzuorgeln. Nach der For-Schleife ab Zeile 36, die alle gefundenen Tabellen-Tupel abarbeitet, liegen sämtliche Messpunkte in einem Array aus Elementen des Typs »clusters.Observations« aus dem k-Means-Paket [6].

Der Aufruf »km.Partition()« teilt die Messpunkte dann zehn verschiedenen Clustern zu. Daraus sortiert Zeile 60 anschließend Mini-Cluster mit weniger als drei Einträgen aus. Das verhindert, dass in jedem Spiel wieder dieselben Fotos vorkommen, ohne dass der Algorithmus eine Chance hätte, mit Zufallsziehungen innerhalb eines Clusters für Abwechslung zu sorgen. Aus den restlichen Clustern wählt der Algorithmus maximal sechs aus (»maxClusters«) und würfelt deren Reihenfolge mit der Shuffle-Funktion aus dem Paket rand zufällig durch.

Da die k-Means-Cluster-Library von Github sich nicht mit Fotosammlungen auskennt, sondern nur Punkte mit X/Y-Koordinaten sortieren kann, legt Zeile 41 eine Hashmap »lookup« an. Sie ordnet die geografische Länge und Breite der Fotos wieder den JPEG-Bildern auf der Platte zu. Kommt der Algorithmus später mit den Koordinaten eines Bilds daher, kann das Programm das zugehörige Bild finden, laden und anzeigen.

Kontrollierter Zufall

Aus den Repräsentanten aller gewählten Cluster muss das Schnitzle-Spiel anfangs ein geheimes Lösungsbild auswählen, das es zu erraten gilt. Es soll das Spiel mit diesem Startbild eröffnen, aber am besten nicht gleich mit der Lösung ins Haus fallen. Die Standardlösung »rand.Intn(len(N))« kommt mit zufällig gleichverteilten Indexpositionen zwischen 0 (einschließlich) und »len(N)« (ausschließlich) daher, zieht also rein zufällige Elemente aus dem Array.

Die Funktion »randPickExcept()« ab Zeile 72 von Listing 2 wählt nun ein zufälliges Element aus dem hereingereichten Array, ohne dabei jemals mit dem auf dem Platz »notIdx« stehenden herauszuplatzen. Das gelingt, indem der Algorithmus aus den Elementen im Indexbereich »0..N« nur Elemente der Indexpositionen »1..N« auswählt. Sollte Kommissar Zufall aber die verbotene Indexposition »notIdx« ziehen, bietet die Funktion einfach die vorher vom Zufall ausgeschlossene Position »0« als Ersatz an.

Gesundschrumpfen

Listing 3 hilft, die Handyfotos verkleinert in die GUI zu bringen. Viele Mobiltelefone frönen der Unart, die Pixel eines Fotos bei der Aufnahme verdreht abzuspeichern und im Header zu vermerken, dass das Bild bei der Darstellung um 90 oder 180 Grad gedreht werden muss [7].

Derlei Schnickschnack erledigt das Paket imageorient, das Listing 3 in Zeile 5 von Github hereinzieht. Außerdem will wohl kaum jemand Riesenfotos auf dem Bildschirm herumschubsen. Als Ersatz fertigt das Paket nfnt/resize (ebenfalls auf Github beheimatet) mit der Funktion »Thumbnail()« in Zeile 26 aus den großformatigen Fotos handliche Thumbnails an.

Listing 3

image.go

package main
import (
  "fyne.io/fyne/v2/canvas"
  "github.com/disintegration/imageorient"
  "github.com/nfnt/resize"
  "image"
  "os"
)
const DspWidth = 300
const DspHeight = 150
func dispDim(w, h int) (dw, dh int) {
  if w > h {
    // landscape
    return DspWidth, DspHeight
  }
  // portrait
  return DspHeight, DspWidth
}
func scaleImage(img image.Image) image.Image {
  dw, dh := dispDim(img.Bounds().Max.X,
    img.Bounds().Max.Y)
  return resize.Thumbnail(uint(dw),
    uint(dh), img, resize.Lanczos3)
}
func showImage(img *canvas.Image, path string) {
  nimg := loadImage(path)
  img.Image = nimg.Image
  img.FillMode = canvas.ImageFillOriginal
  img.Refresh()
}
func loadImage(path string) *canvas.Image {
  f, err := os.Open(path)
  panicOnErr(err)
  defer f.Close()
  raw, _, err := imageorient.Decode(f)
  panicOnErr(err)
  img := canvas.NewImageFromResource(nil)
  img.Image = scaleImage(raw)
  return img
}

In welcher Distanz die Aufnahmeorte zueinander liegen und in welchem Winkel von 0 bis 360 Grad man vom im Startbild gezeigten Platz aus losmarschieren müsste, berechnet Listing 4 mit den passenden mathematischen Formeln [8]. Die Funktion »hike()« nimmt in Zeile 8 die geografische Länge (»lngN«) und Breite (»latN«) aus den GPS-Daten zweier Fotos entgegen und greift auf die Funktionen der Library golang-geo zurück, zu denen unter anderem »GreatCircleDistance()« und »BearingTo()« zum Ermitteln von Distanz und Anfangswinkel zählen.

Um aus dem Anfangswinkel »bearing« der Marschroute von 0 bis 360 Grad eine Himmelsrichtung wie Norden oder Nordosten zu machen, teilt Zeile 16 den Winkel durch 45, rundet das Ergebnis auf die nächste Ganzzahl und greift dann unter diesem Index auf das Array-Slice in Zeile 15 zu. Dort steht auf Index »0« das »N« für Nord, unter »1« »NE« für Nordost und so weiter. Fällt der Index unter 0, was bei negativen Winkeln vorkommt, zählt Zeile 19 einfach die Länge des Arrays-Slices hinzu und kommt so auf einen Index, der das Array-Slice von hinten adressiert.

Listing 4

gps.go

package main
import (
  geo "github.com/kellydunn/golang-geo"
  "math"
)
func hike(lat1, lng1, lat2, lng2 float64) (float64, string, error) {
  p1 := geo.NewPoint(lat1, lng1)
  p2 := geo.NewPoint(lat2, lng2)
  bearing := p1.BearingTo(p2)
  dist := p1.GreatCircleDistance(p2)
  names := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW", "N"}
  idx := int(math.Round(bearing / 45.0))
  if idx < 0 {
    idx = idx + len(names)
  }
  return dist, names[idx], nil
}

Widget mit Extrawurst

Die GUI-Applikation »schnitzle« nutzt das Framework Fyne, um mit reinem Go-Code eine nativ aussehende grafische Applikation auf den Desktop zu zaubern. Zurückliegende Snapshot-Beiträge [9] haben schon manches Mal in den Tiefen dieses hervorragenden Tools [10] herumgeschnuppert.

Fyne bringt von Haus aus eine ganze Palette an Label-, Listbox-, Button- und sonstigen Widgets mit, kann aber nicht jeden Sonderfall abdecken. Im Schnitzle-Spiel klickt der Ratende zum Beispiel auf ein Foto auf der rechten Seite, um es nach links zu befördern. Fotos nehmen normalerweise keine Klicks entgegen, wohl aber Button-Widgets, die wiederum Callbacks definieren, um im Alarmfall die beabsichtigte Aktion auszuführen.

Mit etwas Code lassen sich in Fyne aber schnell Erweiterungen programmieren. Listing 5 definiert ab Zeile 13 einen neuen Widget-Typ namens »clickImage()«. Er wird aus einem Canvas-Objekt mit einem Vorschaubild zusammengebaut und nimmt einen Callback entgegen, den es aufruft, wenn der User mit der Maus auf das Foto klickt.

Dank des in Go eingebauten Vererbungsmechanismus für Strukturen leitet »widget.BaseWidget« in der ersten Zeile der Struktur selbige von Fynes Basis-Widget ab, versorgt es also mit den allen Widgets gemeinsamen Funktionen zum Darstellen, Schrumpfen oder Verhüllen. Zudem muss das Zusatz-Widget später im Konstruktor in Zeile 21 die Funktion »ExtendBaseWidget()« aufrufen, damit es in den Genuss aller Segnungen der GUI kommt.

Und noch etwas: Die grafische Oberfläche weiß bislang noch nicht, wie sie das neue Widget auf den Bildschirm zaubern soll. Die Funktion »CreateRenderer()« ab Zeile 27 liefert deswegen ein Objekt vom Typ »NewSimpleRenderer« mit dem dargestellten Bild als Parameter an die GUI.

Listing 5

gui.go

package main
import (
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/widget"
  "math/rand"
  "os"
  "time"
)
type clickImage struct {
  widget.BaseWidget
  image *canvas.Image
  cb    func()
}
func newClickImage(img *canvas.Image, cb func()) *clickImage {
  ci := &clickImage{}
  ci.ExtendBaseWidget(ci)
  ci.image = img
  ci.cb = cb
  return ci
}
func (t *clickImage) CreateRenderer() fyne.WidgetRenderer {
  return widget.NewSimpleRenderer(t.image)
}
func (t *clickImage) Tapped(_ *fyne.PointEvent) {
  t.cb()
}
func makeUI(w fyne.Window, p fyne.Preferences) {
  rand.Seed(time.Now().UnixNano())
  var leftCard *widget.Card
  var rightCard *widget.Card
  quit := widget.NewButton("Quit", func() {
    os.Exit(0)
  })
  var restart *widget.Button
  reload := func() {
    leftCard, rightCard = makeGame(p)
    vbox := container.NewVBox(
      container.NewGridWithColumns(2, quit, restart),
      container.NewGridWithColumns(2, leftCard, rightCard),
    )
    w.SetContent(vbox)
    canvas.Refresh(vbox)
  }
  restart = widget.NewButton("New Game", func() {
    reload()
  })
  reload()
}

Damit nun die Foto-Widgets in Schnitzle auf Mausklicks reagieren, nimmt deren ab Zeile 19 definierter Konstruktor »newClickImage()« einen Callback entgegen, den das Widget später auf einen Klick hin aufruft. Zeile 23 weist diese Funktion der Instanzvariablen »cb« zu. Später löst die Funktion »Tapped()« (ab Zeile 31, von der GUI aufgerufen) einfach in Zeile 32 den vorher gesetzten Callback aus, sobald der Ratefreund auf das Widget klickt. Fertig ist ein neues, über ein klickbares Foto gesteuertes GUI-Element, das sich ähnlich wie ein Button-Widget verhält.

Ähnlich wie der Quit-Button ab Zeile 41, der auf Knopfdruck via »os.Exit(0)« über sein Callback das Spielende einläutet, kann Listing 6 in Zeile 31 ein neues klickbares Image erzeugen. In seinem Callback schickt es den geklickten Index in den Channel »pickCh«, an dessen anderem Ende der Spielapparat es aufschnappt und grafisch animiert.

Der Rest von Listing 5 widmet sich mit »makeUI« dem Arrangieren der im Spiel gezeigten Widgets. Die zentrale Funktion »reload()« lädt nach dem Programmstart sowie nach jedem Druck auf New Game ein neues Spiel.

Karteikarte als Modell

Das Arrangement der grafischen Elemente im Spielfenster umfasst zwei horizontal angeordnete Knöpfe am oberen Ende, gefolgt (mittels vertikaler Stapelung durch »NewVBox()«) von zwei Bildkolumnen jeweils vom Typ »Card«. Dieses Standard-Widget aus der Fyne-Sammlung zeigt eine Überschrift (optional eine Unterschrift) sowie ein Bild zur Illustration. Sie können sich das Ganze wie eine beschriebene Karteikarte vorstellen.

Listing 6 definiert die Hauptfunktion »main()« und legt in »makeGame()« die einzelnen Widgets der linken und rechten Spielkolumne fest. Hinzu kommt die Bewegungsmaschinerie, die sich in Gang setzt, falls der Spieler auf ein Foto in der rechten Spalte klickt.

Das Array »pool« enthält die jeweils als »CanvasObject« dargestellten Fotos der rechten Spalte als Elemente. Die linke Widget-Spalte hingegen zeigt die im Spielverlauf bereits ausgewählten Fotos. Sie stehen im anfangs leeren Array »left«. Bei jedem Klick auf ein Foto in der rechten Widget-Spalte »right« schickt der dem jeweiligen Foto zugeordnete Callback den Index der Auswahl in Zeile 31 in den Channel »pickCh«.

Dort schnappt ihn die ab Zeile 45 definierte und parallel laufende Go-Routine mit »select« auf. Sie rechnet mit »hike« in Zeile 50 die Entfernung zum Lösungsbild aus und erzeugt in Zeile 62 eine Fyne-»Card« mit dem Ergebnis. Das hängt die Funktion »Add()« in Zeile 63 unten an die linke Spalte an und sorgt mit »canvas.Refresh()« dafür, dass die GUI die Änderung auch anzeigt.

Listing 6

schnitzle.go

package main
import (
  "fmt"
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/app"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/widget"
  "math/rand"
)
func makeGame(p fyne.Preferences) (*widget.Card, *widget.Card) {
  pickCh := make(chan int)
  done := false
  photos, err := photoSet()
  panicOnErr(err)
  photosRight := make([]Photo, len(photos))
  copy(photosRight, photos)
  pool := []fyne.CanvasObject{}
  for i, photo := range photosRight {
    idx := i
    img := canvas.NewImageFromResource(nil)
    img.SetMinSize(fyne.NewSize(DspWidth, DspHeight))
    clkimg := newClickImage(img, func() {
      if !done {
        pickCh <- idx
      }
    })
    pool = append(pool, clkimg)
    showImage(img, photo.Path)
  }
  solutionIdx := rand.Intn(len(photos))
  solution := photos[solutionIdx]
  left := container.NewVBox()
  right := container.NewVBox(pool...)
  go func() {
    for {
      select {
      case i := <-pickCh:
        photo := photos[i]
        dist, bearing, err := hike(photo.Lat, photo.Lng, solution.Lat, solution.Lng)
        panicOnErr(err)
        if photo.Path == solution.Path {
          done = true
        }
        subText := ""
        if done == true {
          subText = "*** WINNER ***"
        }
        card := widget.NewCard(fmt.Sprintf("%.1fkm %s", dist, bearing), subText, pool[i])
        left.Add(card)
        canvas.Refresh(left)
        pool[i] = widget.NewLabel("")
        pool[i].Hide()
        if done == true {
          return
        }
      }
    }
  }()
  first := randPickExcept(photos, solutionIdx)
  pickCh <- first
  return widget.NewCard("Picked", "", left),
    widget.NewCard("Pick next", "", right)
}
func panicOnErr(err error) {
  if err != nil {
    panic(err)
  }
}
func main() {
  a := app.NewWithID("com.example.schnitzle")
  w := a.NewWindow("Schnitzle Geo Worlde")
  pref := a.Preferences()
  makeUI(w, pref)
  w.ShowAndRun()
}

Damit das angeklickte Foto aus der rechten Spalte verschwindet, setzt Zeile 66 ein leeres »Label«-Widget an seine Stelle, das es mit »Hide()« gleich wieder wegzaubert.

Am Spielanfang beginnt der Reigen, indem Zeile 76 mittels »randPickExcept()« ein zufälliges Foto aus der rechten Spalte auswählt, aber sicherstellt, nicht gleich mit der Lösung herauszuplatzen. Zeile 77 schiebt die Indexposition in den Channel »pickCh«, ganz so, wie das ein ausgelöster Callback eines vom User ausgewählten Foto-Widgets später tun würde, und setzt damit dieselbe Animation in Gang.

In Go muss der Programmierer praktisch nach jedem Funktionsaufruf prüfen, ob sich nicht ein Fehler eingeschlichen hat. Zurück kommt immer eine Variable »err«. Hat sie den Wert »nil«, trat kein Fehler auf. Die entsprechende Abfrage erfordert jedes Mal drei Zeilen Code, was bei in Zeitschriften abgedruckten Listings Unmengen von Platz beansprucht.

Deswegen definiert Zeile 82 einfach eine Funktion »panicOnErr()«, die diesen Test jeweils in einer Zeile Code ausführt und im Fehlerfall das Programm kurzerhand mit »panic()« abbricht. In Produktionsumgebungen behandelt man Fehler stattdessen individuell und schleift sie oft nach weiter oben im Call-Stack durch.

Los geht’s

Die ganze Chose kompilieren Sie mit den Befehlen aus Listing 7. Das resultierende Binary »schnitzle« starten Sie aus der Kommandozeile, woraufhin es die GUI auf den Bildschirm zaubert.

Listing 7

Schnitzle kompilieren

$ go mod init schnitzle
$ go mod tidy
$ go build schnitzle.go gui.go photoset.go image.go gps.go

Unter Linux klinkt sich die Fyne-GUI mittels eines C-Wrappers aus Go in die Bibliotheken libx11-dev, libgl1-mesa-dev, libxcursor-dev und xorg-dev ein, die Sie zum Beispiel unter Ubuntu mit dem Kommando »sudo apt-get install« nachinstallieren, damit »go build« auch das notwendige Desktop-Fundament vorfindet.

Fazit

Manchmal ist es gar nicht so einfach herauszubekommen, in welcher Himmelsrichtung ein anderer Stadtteil liegt. Oft war ich vom Ergebnis überrascht, selbst bei Orten, von denen ich eigentlich dachte, ich würde mich dort auskennen. Das Spiel bietet Sternstunden der Unterhaltung für die ganze Familie, wenn es längst vergessene Fotos aus einer über die Jahre auf erstaunliche Ausmaße angewachsenen Sammlung hervorkramt. (uba)

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

Infos

  1. Wordle: https://www.nytimes.com/games/wordle/index.html
  2. Worldle: https://worldle.teuteuf.fr/
  3. k-Means-Algorithmus: https://de.wikipedia.org/wiki/K-Means-Algorithmus
  4. Perl-Snapshot: Mike Schilli, “Sehen lernen”, LM 11/2012, S. 104, https://www.lm-online.de/26948
  5. Unüberwachtes Lernen: https://de.wikipedia.org/wiki/Un%C3%BCberwachtes_Lernen
  6. Bibliothek kmeans auf Github: https://github.com/muesli/kmeans
  7. Snapshot: Mike Schilli, “Dreh dich im Kreis”, LM 02/2022, S. 82, https://www.lm-online.de/45751
  8. “Calculate distance, bearing and more between Latitude/Longitude points”: http://www.movable-type.co.uk/scripts/latlong.html
  9. Snapshot: Mike Schilli, “Bogenlampe”, LM 12/2021, S. 76, https://www.lm-online.de/44816
  10. Snapshot: Mike Schilli, “Ab ins Kröpfchen”, LM 11/2021, S. 86, https://www.lm-online.de/44800
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 9 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