Schnitzeljagd (Linux-Magazin, Juli 2022)

Nach dem durchschlagenden Erfolg des Wortratespiels "Wordle" [1] ließen die Nachahmer nicht lange auf sich warten. Einer der besten Trittbrettfahrer ist das unterhaltsame Geografie-Wordle "Worldle" auf https://worldle.teuteuf.fr, bei dem der Spieler ein Land auf dem Erdball erraten muss. Bei jedem Rateversuch hilft der Server dem Spieler mit der Information weiter, wie weit der Rateversuch noch vom Zielland entfernt ist, und in welcher Himmelsrichtung sich der gewünschte Ort befindet.

Der Umrisse des gesuchten Landes unkundig, tippt der Spieler in Abbildung 1 zunächst auf den Zwergstaat Liechtenstein, doch der Worldle-Server weist darauf hin, dass der gesuchte Ort 5371km von Liechtenstein entfernt liegt, und zwar in östlicher Richtung (Pfeil nach rechts). Als zweiter Rateversuch kam daraufhin Belarus, doch auch von dort sind es laut Worldle-Server noch 4203km in südöstlicher Richtung zum Zielort. Die Mongolei (Mongolia auf Englisch) als dritter Rateversuch schoss übers Ziel hinaus, denn von dort sind es noch 3476 in südwestlicher Richtung zum gesuchten Land. Damit ging dem Spieler langsam ein Licht auf, und er kam zu der Erkenntnis, dass sich das gesuchte Land im Großraum Indiens befände, und erriet Pakistan im vierten Versuch. Ein Riesenspaß, und jeden Tag kommt ein neues Land dran.

Abbildung 1: Das original Geografie-Wordle "Worldle".

Privatfotos enthüllt

Statt Länder der Erde zu erraten, dachte ich mir nun, dass es ganz amüsant wäre, eine Handvoll Fotos zufällig aus meiner riesigen, über Jahre wild gewachsenen Handyfoto-Privatsammlung auszuwählen, und darauf zu achten, dass ihre GPS-Information sie als relativ weit von einander entfernt liegend ausweisen. Ein zufälliges Foto aus dem Set wählt der Computer anfangs als gesuchte Lösung aus, hält diese aber geheim und tischt dem Spieler statt dessen eine ebenfalls zufällig ausgewählte Aufnahme auf, samt der Information, wieviele Kilometer es von dort noch zum Lösungsbild sind, und in welcher Himmelsrichtung der Spieler sich dazu auf dem Globus bewegen müsste.

Mit dieser Information gewappnet, versucht der Spieler nun, zu erraten, welches Bild aus der verbleibenden Auswahl wohl das Gesuchte ist, klickt es mit der Maus an, und bekommt die Bewertung seines Rateversuchs wieder mit Kilometerangabe und Himmelsrichtung mitgeteilt. Schafft der Spieler es, sich so gedanklich in möglichst wenig Zügen zur Lösung vorzuarbeiten, ist das Spiel gewonnen. Eine Art Schnitzeljagd also, und dem Wordle-Thema folgend heißt das Programm "Schnitzle".

Fotos auf dem Handy gibt's dazu genug zur Auswahl, und ein Zufallsgenerator sorgt dafür, dass das Spiel immer neue Fotos auswählt, so dass es nie langweilig wird.

Und ... Action!

Abbildung 2: Ausgangsposition: Schnitzle wählt ein Foto 8000km vom Zielort.

Abbildung 3: Der Spieler wählt ein Foto vom Pinnacle-Nationalpark, das noch 168km entfernt ist.

Abbildung 2 zeigt das Spiel in Aktion, der Computer hat als Startbild ein Bergfoto vom Aggenstein in den bayrischen Alpen ausgewählt. Laut Hinweis ist es 9457km vom Zielfoto entfernt, und zwar in nordwestlicher (NW) Richtung. Das muss wohl in Amerika liegen! Aus der Auswahl rechts klickt der User dann (Abbildung 3) auf ein Foto vom Pinnacle-Nationalpark in Kalifornien, und zeigt an, dass es von dort noch 168km, ebenfalls in nordwestlicher Richtung, zur Lösung sind. Nördlich vom Pinnacles, hmm, das muss in der San Francisco Bay Area sein! Also klickt der Spieler in Abbildung 4 das Foto vom Parkplatz am Strand in Pacifica, wo ich oft surfen gehe. Aber auch von dort sind es noch 10km bis zum gesuchten Foto, nach Nordosten (NE), und da schwante es mir schon: Die Lösung muss im Vorort South San Francisco liegen, wo der Riesensupermarkt "Costco" steht, und tatsächlich ist dessen Grillstation mit den aufgereihten Brathendl die Lösung, wie die "*** Winner ***" Meldung illustriert (Abbildung 5). Ungeklickt verbleiben die beiden Fotos in der rechten Spalte, die eine Brücke in Heidelberg und den Sand an einem Pazifik-Strand in der Bay Area zeigen.

Abbildung 4: Der Parkplatz am Strand von Pacifica ist noch 10km weg.

Abbildung 5: Die Lösung: Die Grill-Station 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 Helfer-Programm finder.go in Listing 1 die Untiefen eines in Zeile 18 eingestellten Handyfoto-Verzeichnisses, analysiert dort jedes gefundene JPG-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, ist mit einem Shell-Befehl wie in Abbildung 6 schnell erzeugt.

Abbildung 6: Eine leere Foto-Datenbank, erzeugt mit dem C-Client.

Nun darf das mit go build finder.go kompilierte Programm in Listing 1 loslegen. Es zieht sich mit go-sqlite3 und goexif2 zwei Bibliotheken von Github herein, eine zum Ansteuern der Flatfile-Datenbank, und eine zum Auslesen der GPS-Header in JPG-Fotos. Damit der Go-Compiler dies ohne Murren erledigt, muss der User mit go mod init finder; go mod tidy zuerst ein Go-Modul definieren, das die im Source-Code eingebundenen Libraries analysiert, 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. Von der Kommandozeile aufgerufen, las finder die etwa 4.000 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).

Listing 1: finder.go

    01 package main
    02 
    03 import (
    04   "database/sql"
    05   "fmt"
    06   _ "github.com/mattn/go-sqlite3"
    07   exif "github.com/xor-gate/goexif2/exif"
    08   "os"
    09   "path/filepath"
    10   rex "regexp"
    11 )
    12 
    13 type Walker struct {
    14   Db *sql.DB
    15 }
    16 
    17 func main() {
    18   searchPath := "photos"
    19 
    20   db, err := sql.Open("sqlite3", "photos.db")
    21   w := &Walker{ Db: db }
    22   err = filepath.Walk(searchPath, w.Visit)
    23   panicOnErr(err)
    24 
    25   db.Close()
    26 }
    27 
    28 func (w *Walker) Visit(path string,
    29   f os.FileInfo, err error) error {
    30   jpgMatch := rex.MustCompile("(?i)JPG$")
    31   match := jpgMatch.MatchString(path)
    32   if !match {
    33     return nil
    34   }
    35 
    36   lat, long, err := GeoPos(path)
    37   panicOnErr(err)
    38 
    39   stmt, err := w.Db.Prepare("INSERT INTO files VALUES(?,?,?)")
    40   panicOnErr(err)
    41   fmt.Printf("File: %s %.2f/%.2f\n", path, lat, long)
    42   _, err = stmt.Exec(path, lat, long)
    43   panicOnErr(err)
    44   return nil
    45 }
    46 
    47 func GeoPos(path string) (float64,
    48   float64, error) {
    49   f, err := os.Open(path)
    50   if err != nil {
    51     return 0, 0, err
    52   }
    53 
    54   x, err := exif.Decode(f)
    55   if err != nil {
    56     return 0, 0, err
    57   }
    58 
    59   lat, long, err := x.LatLong()
    60   if err != nil {
    61     return 0, 0, err
    62   }
    63 
    64   return lat, long, nil
    65 }

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

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

Für jede gefundene Datei prüft Zeile 31, ob sie auch die Endung .jpg (groß oder klein) trägt, und liest dann in der Funktion GeoPos() ab Zeile 47 die Exif-Daten des Fotos ein, die, falls vorhanden, die geografische Länge und Breite des Aufnahmeorts als Fließkommazahl enthalten.

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

Qual der Wahl

Abbildung 8: Clustering von Fotos auf dem Handy

Ein Dutzend Bilder zufällig aus einer Fotosammlung von mehreren tausend Bildern auszuwählen, ist nicht besonders schwierig, aber sicherzustellen, dass die Fotos einer Spielrunde nicht alle zu nahe zusammenliegen, hingegen schon. Viele Handyfotos entstehen zuhause, und der Spielspaß beim meterweisen Navigieren zwischen Wohnzimmer, Balkon und Küche hält sich in Grenzen. Vielmehr sollte der Algorithmus Bilder zwar zufällig aussuchen, damit unterschiedliche Spielsituationen entstehen, aber immer eine gute Mischung aus unterschiedlichen Regionen präsentieren. Die geografische Ansicht der Foto-App auf dem Handy in Abbildung 8 illustriert, dass die GPS-Daten der Aufnahmen sie gebündelten Hotsports zuweisen, aus denen der Algorithmus jeweils nur ein Bild wählen sollte.

Abbildung 9: Die kmeans-Bibliothek für Go auf Github ([5])

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

Listing 2: photoset.go

    01 package main
    02 
    03 import (
    04   "database/sql"
    05   "fmt"
    06   _ "github.com/mattn/go-sqlite3"
    07   "github.com/muesli/clusters"
    08   "github.com/muesli/kmeans"
    09   "math/rand"
    10 )
    11 
    12 type Photo struct {
    13   Path string
    14   Lat  float64
    15   Lng  float64
    16 }
    17 
    18 func photoSet() ([]Photo, error) {
    19   db, err := sql.Open("sqlite3", "photos.db")
    20   panicOnErr(err)
    21   photos := []Photo{}
    22 
    23   query := fmt.Sprintf("SELECT path, lat, long FROM files")
    24   stmt, _ := db.Prepare(query)
    25 
    26   rows, err := stmt.Query()
    27   panicOnErr(err)
    28 
    29   var d clusters.Observations
    30   lookup := map[string]Photo{}
    31 
    32   keyfmt := func(lat, lng float64) string {
    33     return fmt.Sprintf("%f-%f", lat, lng)
    34   }
    35 
    36   for rows.Next() {
    37     var path string
    38     var lat, lng float64
    39     err = rows.Scan(&path, &lat, &lng)
    40     panicOnErr(err)
    41     lookup[keyfmt(lat, lng)] = Photo{Path: path, Lat: lat, Lng: lng}
    42     d = append(d, clusters.Coordinates{
    43       lat,
    44       lng,
    45     })
    46   }
    47 
    48   db.Close()
    49 
    50   maxClusters := 6
    51   km := kmeans.New()
    52   clusters, err := km.Partition(d, 10)
    53   panicOnErr(err)
    54 
    55   rand.Shuffle(len(clusters), func(i, j int) {
    56     clusters[i], clusters[j] = clusters[j], clusters[i]
    57   })
    58 
    59   for _, c := range clusters {
    60     if len(c.Observations) < 3 {
    61       continue
    62     }
    63     rndIdx := rand.Intn(len(c.Observations))
    64     coords := c.Observations[rndIdx].Coordinates()
    65     key := keyfmt(coords[0], coords[1])
    66     photo := lookup[key]
    67     photos = append(photos, photo)
    68     if len(photos) == maxClusters {
    69       break
    70     }
    71   }
    72   return photos, nil
    73 }
    74 
    75 func randPickExcept(pick []Photo, notIdx int) int {
    76   idx := rand.Intn(len(pick)-1) + 1
    77   if idx == notIdx {
    78     idx = 0
    79   }
    80   return idx
    81 }

Aufgabe der Funktion photoSet() ab Zeile 18 in Listing 2 ist es, ein Array-Slice von sechs Fotos vom Typ Photo für ein neues Spiel zu liefern. Zeile 12 definiert die Datenstruktur Photo mit den Komponenten path für den Dateipfad zur Bildatei, sowie die aus den Exif-Informationen ausgelesenen Geo-Koordinaten als geografische Länge Lng und Breite Lat, jeweils als 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 Tabellentupel abarbeitet, liegen alle Messpunkte in einem Array aus Elementen vom Typ clusters.Observations des K-Means-Pakets.

Der Aufruf km.Partition() teilt die Messpunkte dann zehn verschiedenen Clustern zu. Aus diesen sortiert Zeile 60 dann Mini-Cluster mit weniger als 3 Einträgen aus, um zu verhindern, dass letztere in jedem Spiel mit den immer gleichen Fotos vorkommen, ohne dass der Algorithmus eine Chance hätte, durch Zufallsziehungen innerhalb eines Clusters für Abwechslung zu sorgen. Aus den zehn errechneten Clustern wählt der Algorithmus maximal 6 aus (maxClusters) und würfelt deren Reihenfolge mit der Shuffle-Funktion aus dem rand-Paket 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, die die geografische Länge und Breite der Fotos wieder den JPG-Bildern auf der Platte zuordnet. Kommt der Algorithmus später mit den Koordinaten eines Bildes daher, dann das Programm das zugehörige Foto finden, laden und anzeigen.

Kontrollierter Zufall

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

Die Funktion randPickExcept() ab Zeile 72 in Listing 2 wählt nun auch ein zufällig ausgewähltes Element aus dem hereingereichten Array, aber ohne jemals mit dem auf Index notIdx stehenden herauszuplatzen. Das geht, 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 Handy-Fotos verkleinert in die GUI zu pflanzen. Viele Handys fröhnen 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 zu drehen ist ([8]). Dergleichen Schnickschnack erledigt das Paket imageorient von Github, das Listing 3 in Zeile 5 hereinzieht. Weiter will wohl kaum jemand Riesenfotos auf dem Bildschirm herumschubsen, sondern nur verkleinerte Thumbnails, die das Paket nfnt/resize, ebenfalls von Github, mit der Funktion Thumbnail() in Zeile 26 aus den großformatigen Fotos anfertigt.

Listing 3: image.go

    01 package main
    02 
    03 import (
    04   "fyne.io/fyne/v2/canvas"
    05   "github.com/disintegration/imageorient"
    06   "github.com/nfnt/resize"
    07   "image"
    08   "os"
    09 )
    10 
    11 const DspWidth = 300
    12 const DspHeight = 150
    13 
    14 func dispDim(w, h int) (dw, dh int) {
    15   if w > h {
    16     // landscape
    17     return DspWidth, DspHeight
    18   }
    19   // portrait
    20   return DspHeight, DspWidth
    21 }
    22 
    23 func scaleImage(img image.Image) image.Image {
    24   dw, dh := dispDim(img.Bounds().Max.X,
    25     img.Bounds().Max.Y)
    26   return resize.Thumbnail(uint(dw),
    27     uint(dh), img, resize.Lanczos3)
    28 }
    29 
    30 func showImage(img *canvas.Image, path string) {
    31   nimg := loadImage(path)
    32   img.Image = nimg.Image
    33 
    34   img.FillMode = canvas.ImageFillOriginal
    35   img.Refresh()
    36 }
    37 
    38 func loadImage(path string) *canvas.Image {
    39   f, err := os.Open(path)
    40   panicOnErr(err)
    41   defer f.Close()
    42   raw, _, err := imageorient.Decode(f)
    43   panicOnErr(err)
    44 
    45   img := canvas.NewImageFromResource(nil)
    46   img.Image = scaleImage(raw)
    47 
    48   return img
    49 }

Um auszurechnen, wieviele Kilometer ein Bild vom anderen entfernt aufgenommen wurde, und in welchem Winkel von 0 bis 360 Grad man vom Startbild aus losmarschieren müsste, rechnet Listing 4 nach den mathematischen Formeln von [7] aus. Die Funktion hike() nimmt in Zeile 8 die geografische Länge (lng) und Breite (lat) in den GPS-Daten zweier Fotos entgegen und rechnet mit der Library golang-geo, die unter anderem die Funktionen GreatCircleDistance() und BearingTo() zur Ermittlung von Distanz und Anfangswinkel bereitstellt.

Abbildung 10: Auf dem Route von A nach B auf dem Globus ändert sich die Himmelsrichtung kontinuierlich. (https://commons.wikimedia.org/w/index.php?curid=4821558)

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

Listing 4: gps.go

    01 package main
    02 
    03 import (
    04   geo "github.com/kellydunn/golang-geo"
    05   "math"
    06 )
    07 
    08 func hike(lat1, lng1, lat2, lng2 float64) (float64, string, error) {
    09   p1 := geo.NewPoint(lat1, lng1)
    10   p2 := geo.NewPoint(lat2, lng2)
    11 
    12   bearing := p1.BearingTo(p2)
    13   dist := p1.GreatCircleDistance(p2)
    14 
    15   names := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW", "N"}
    16   idx := int(math.Round(bearing / 45.0))
    17 
    18   if idx < 0 {
    19     idx = idx + len(names)
    20   }
    21 
    22   return dist, names[idx], nil
    23 }

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 Snapshotausgaben wie [9] oder [10] haben schon manches Mal in den Tiefen dieses hervorragenden Tools herumgeschnuppert.

Fyne bringt von Haus aus eine ganze Palette an Label-, Listbox-, Button- und was auch sonst noch für -Widgets mit, kann aber natürlich nicht jeden Sonderfall abdecken. Im Schnitzle-Spiel klickt der User zum Beispiel auf ein Foto auf der rechten Seite, um es nach links zu befördern. Fotos nehmen normalerweise keine Klicks entgegen, wie Button-Widgets, die wiederum Callbacks defininieren, um im Alarmfall die vom User 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(), zusammengebaut aus einem Canvas-Objekt mit einem Thumbnail-Foto, das einen Callback entgegennimmt, den es aufruft, wenn der User mit der Maus auf das Foto klickt. Dank dem 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 allen Widgets gemeinen Funktionen zu deren Darstellung, Schrumpfung oder Verhüllung. Weiter muss das Zusatz-Widget später im Konstruktor in Zeile 21 die Funktion ExtendBaseWidget() aufrufen, damit es alle Segnungen der GUI erhält. Und noch etwas: Die GUI 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

    01 package main
    02 
    03 import (
    04   "fyne.io/fyne/v2"
    05   "fyne.io/fyne/v2/canvas"
    06   "fyne.io/fyne/v2/container"
    07   "fyne.io/fyne/v2/widget"
    08   "math/rand"
    09   "os"
    10   "time"
    11 )
    12 
    13 type clickImage struct {
    14   widget.BaseWidget
    15   image *canvas.Image
    16   cb    func()
    17 }
    18 
    19 func newClickImage(img *canvas.Image, cb func()) *clickImage {
    20   ci := &clickImage{}
    21   ci.ExtendBaseWidget(ci)
    22   ci.image = img
    23   ci.cb = cb
    24   return ci
    25 }
    26 
    27 func (t *clickImage) CreateRenderer() fyne.WidgetRenderer {
    28   return widget.NewSimpleRenderer(t.image)
    29 }
    30 
    31 func (t *clickImage) Tapped(_ *fyne.PointEvent) {
    32   t.cb()
    33 }
    34 
    35 func makeUI(w fyne.Window, p fyne.Preferences) {
    36   rand.Seed(time.Now().UnixNano())
    37 
    38   var leftCard *widget.Card
    39   var rightCard *widget.Card
    40 
    41   quit := widget.NewButton("Quit", func() {
    42     os.Exit(0)
    43   })
    44 
    45   var restart *widget.Button
    46 
    47   reload := func() {
    48     leftCard, rightCard = makeGame(p)
    49     vbox := container.NewVBox(
    50       container.NewGridWithColumns(2, quit, restart),
    51       container.NewGridWithColumns(2, leftCard, rightCard),
    52     )
    53     w.SetContent(vbox)
    54     canvas.Refresh(vbox)
    55   }
    56 
    57   restart = widget.NewButton("New Game", func() {
    58     reload()
    59   })
    60 
    61   reload()
    62 }

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 Klick aufruft. Diese Funktion weist Zeile 23 der Instanzvariablen cb zu. Später ruft die Funktion Tapped() (ab Zeile 31), aufgerufen von der GUI, wenn der User auf das Widget klickt, einfach in Zeile 32 den vorher gesetzten Callback auf. Fertig ist ein neues GUI-Element, das ein klickbares Foto steuert, das sich ähnlich wie ein Button-Widget verhält.

Ähnlich wie der Quit-Button ab Zeile 41, der auf Knopfdruck mit der Maus mit os.Exit(0) über sein Callback das Spielende einläutet, kann dann Listing 6 später in Zeile 31 ein neues klickbares Image erzeugen, und in seinem Callback den geklickten Index in den Channel pickCh schicken, 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. Zentral ist die Funktion reload(), die nach Programmstart oder nach Knopfdruck auf New Game ein neues Spiel lädt.

Karteikarte als Modell

Das Arrangement der GUI-Elemente im Spielfenster besteht aus zwei horizontal angeordneten Knöpfen 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, und ein Image zur Illustration. Man kann sich das Ganze als beschriebene Karteikarte vorstellen.

Listing 6 schließlich definiert die Hauptfunktion main(), sowie in makeGame() die Einzelwidgets der linken und rechten Spielkolumne, sowie die Bewegungsmaschinerie, die sich in Gang setzt, falls der Spieler auf ein Foto in der rechten Spalte klickt.

Der Array pool enthält die jeweils als CanvasObject dargestellten Fotos der rechten Spalte als Elemente. Die linke Widget-Spalte zeigt hingegen die bereits im Spielverlauf ausgewählten Fotos. Sie stehen im Array left, der anfangs leer ist. Bei jedem Klick auf ein Foto in der rechten Widgetspalte right schickt der dem jeweiligen Foto zugeordnete Callback den Index der Auswahl in Zeile 31 in den Channel pickCh. Von dort schnappt ihn die ab Zeile 45 definierte und parallel laufende Goroutine mit select auf, rechnet mit hike in Zeile 50 die Entfernung zum Lösungsbild aus, und erzeugt in Zeile 62 eine Fyne-Card mit dem Ergebnis. Die Funktion Add() in Zeile 63 hängt diese dann unten an die linke Spalte an und sorgt mit canvas.Refresh() dafür, dass die GUI die Änderung auch anzeigt.

Listing 6: schnitzle.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "fyne.io/fyne/v2"
    06   "fyne.io/fyne/v2/app"
    07   "fyne.io/fyne/v2/canvas"
    08   "fyne.io/fyne/v2/container"
    09   "fyne.io/fyne/v2/widget"
    10   "math/rand"
    11 )
    12 
    13 func makeGame(p fyne.Preferences) (*widget.Card, *widget.Card) {
    14   pickCh := make(chan int)
    15   done := false
    16 
    17   photos, err := photoSet()
    18   panicOnErr(err)
    19 
    20   photosRight := make([]Photo, len(photos))
    21   copy(photosRight, photos)
    22 
    23   pool := []fyne.CanvasObject{}
    24 
    25   for i, photo := range photosRight {
    26     idx := i
    27     img := canvas.NewImageFromResource(nil)
    28     img.SetMinSize(fyne.NewSize(DspWidth, DspHeight))
    29     clkimg := newClickImage(img, func() {
    30       if !done {
    31         pickCh <- idx
    32       }
    33     })
    34 
    35     pool = append(pool, clkimg)
    36     showImage(img, photo.Path)
    37   }
    38 
    39   solutionIdx := rand.Intn(len(photos))
    40   solution := photos[solutionIdx]
    41 
    42   left := container.NewVBox()
    43   right := container.NewVBox(pool...)
    44 
    45   go func() {
    46     for {
    47       select {
    48       case i := <-pickCh:
    49         photo := photos[i]
    50         dist, bearing, err := hike(photo.Lat, photo.Lng, solution.Lat, solution.Lng)
    51         panicOnErr(err)
    52 
    53         if photo.Path == solution.Path {
    54           done = true
    55         }
    56 
    57         subText := ""
    58         if done == true {
    59           subText = "*** WINNER ***"
    60         }
    61 
    62         card := widget.NewCard(fmt.Sprintf("%.1fkm %s", dist, bearing), subText, pool[i])
    63         left.Add(card)
    64         canvas.Refresh(left)
    65 
    66         pool[i] = widget.NewLabel("")
    67         pool[i].Hide()
    68 
    69         if done == true {
    70           return
    71         }
    72       }
    73     }
    74   }()
    75 
    76   first := randPickExcept(photos, solutionIdx)
    77   pickCh <- first
    78   return widget.NewCard("Picked", "", left),
    79     widget.NewCard("Pick next", "", right)
    80 }
    81 
    82 func panicOnErr(err error) {
    83   if err != nil {
    84     panic(err)
    85   }
    86 }
    87 
    88 func main() {
    89   a := app.NewWithID("com.example.schnitzle")
    90   w := a.NewWindow("Schnitzle Geo Worlde")
    91 
    92   pref := a.Preferences()
    93   makeUI(w, pref)
    94   w.ShowAndRun()
    95 }

Damit das alte Foto aus der rechten Spalte verschwindet, setzt Zeile 66 ein leeres Label-Widget an seine Stelle, das es mit Hide() gleich wieder wegzaubert. "Simsalabim, ignorieren Sie den Mann hinter dem Vorhang!"

Am Spielanfang beginnt der Reigen, indem Zeile 76 mittels randPickExcept() ein zufälliges Foto aus der rechten Spalte auswählt, aber sicherstellt, dass es nicht gleich mit der Lösung herausplatzt. 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 die gleiche Animation in Gang.

In Go muss der Programmierer praktisch nach jedem Funktionsaufruf prüfen, ob sich nicht ein Fehlerlein eingeschlichen hat. Zurück kommt immer eine Variable err, die keinen Fehler anzeigt, falls sie den Wert nil führt. Dies erfordert jedes Mal drei Zeilen Code, was sich in Zeitschriften abgedruckten Listings Unmengen von Platz beansprucht, und deswegen definiert Zeile 82 in Listing 6 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 werden Fehler individuell behandelt, und oft nach weiter oben im Call-Stack hochgeschleift.

Los geht's

Die ganze Chose kompiliert sich mit go mod init schnitzle; go mod tidy; go build schnitzle.go gui.go photoset.go image.go gps.go und heraus kommt ein Binary schnitzle, das sich von der Kommandozeile starten lässt und die GUI auf den Schirm zaubert. 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 der User zum Beispiel auf Ubuntu mit sudo apt-get install nachinstallieren muss, damit go build auch das notwendige Desktop-Fundament findet.

Und los geht's. Manchmal ist es gar nicht so einfach, zu wissen, in welcher Himmelsrichtung ein anderer Stadtteil liegt, und 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 hochbringt!

Infos

[1]

Das Wortratespiel "Wordle", https://www.nytimes.com/games/wordle/index.html

[2]

Geografie-Wordle "Worldle": https://worldle.teuteuf.fr/

[3]

K-Means-Algorithmus, https://de.wikipedia.org/wiki/K-Means-Algorithmus

[4]

Unüberwachtes Lernen, https://de.wikipedia.org/wiki/Un%C3%BCberwachtes_Lernen

[5]

Kmeans-Bibliothek auf Github, https://github.com/muesli/kmeans

[6]

Michael Schilli, "Sehen lernen": Linux-Magazin 11/12, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2012/11/perl-snapshot/<U>

[7]

Distanz und Anfangswinkel einer Globuswanderung berechnen: "Calculate distance, bearing and more between Latitude/Longitude points", http://www.movable-type.co.uk/scripts/latlong.html

[8]

Michael Schilli, "Dreh dich im Kreis", Linux-Magazin 02/22, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2022/02/snapshot/<U>

[9]

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

[10]

Michael Schilli, "Ab ins Kröpfchen", Linux-Magazin 11/21, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2021/11/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