Minen bei Haskell und Gloss: Rapid Prototyping Interactive Graphics

Es gibt bereits viele gute Artikel über Haskell über Habrahabr, aber leider sind dies zum größten Teil alle Arten von Einführungen in FP, Erläuterungen zu einigen theoretischen Stücken (wie Monaden, Lambda-Kalkül oder Typfamilien) und einige praktische Beispiele. In keinem Fall werde ich versuchen, das Ungleichgewicht zu beseitigen, zur undankbaren Aufgabe der Popularisierung des Funktionalismus beizutragen und erneut zu zeigen, dass die Sprache nicht nur zum Schreiben von Fakultäten und ineffektiven schnellen Sortierungen in zwei Zeilen geeignet ist, sondern auch für ganz praktische Dinge wie Rapid Prototyping.

Der Artikel wird versuchen, relativ real zu sein ., aber langweilen Sie den Leser nicht mit Volumen oder exotischen Themenbereichen. "Die Grafik und die Spiele im Training sind immer sexy", wie der große V. S. Lugovsky vererbte, und ich werde ein einfaches Spiel skizzieren, das im Volksmund von Minesweeper geliebt wird. Die Entwicklung wird "von oben nach unten" durchgeführt - dies ist eine seltene, aber bemerkenswerte Methode (wie Haskell selbst), über die ich vor langer Zeit in einem ausgezeichneten Artikel über Checker in "Functional Programming Practice" gelesen habe und die seitdem in mir versunken ist in die Seele.

Kopf zu Fuß


Die Idee ist einfach: Wir fangen nicht mit den zugrunde liegenden elementaren Typen und Funktionen an, indem wir sie zu größeren kombinieren (wie es bei der alltäglichen Entwicklung in gängigen Sprachen üblich ist), sondern beschreiben im Gegenteil die Entitäten auf höchster Ebene und zerlegen sie schrittweise in kleine. Wenn der Code von einem oder zwei Programmierern geschrieben wird und die Erklärung des Problems im Voraus bekannt ist - oder im Gegenteil, ein unverständlicher Prototyp, der sich während der Entwicklung hundertmal ändert, ist dieser Ansatz meiner Meinung nach besser als der klassische. Erstens können Sie damit nicht in die Sackgasse der Implementierungskurve geraten, wenn wir plötzlich mit den falschen Primitiven begonnen haben. Zweitens folgt es dem KISS- Prinzip (bzw. der progressiven JPEG-Methode)) - Sie können auf jeder Ebene der Ausarbeitung aufhören, wenn das Ergebnis zufriedenstellend ist, und nicht auf unnötige Details eingehen, die mit "von unten nach oben" als vorrangig erscheinen können. Drittens vereinfacht das flexible Prototyping die Verfeinerung der Aufgabe und der Anforderungen während der Entwicklung.
(weitere Details können bei Wikipedia nachgelesen werden )

Darüber hinaus zeichnet sich Haskell dadurch aus, dass Sie aufgrund der Faulheit und des Abschlusses von Deklarationstypen und Definitionen von Typen und Funktionen fast jede beliebige Reihenfolge angeben und Code auf einfache Weise schreiben können, fast ohne an den Anfang zurückzukehren, um etwas zu ändern oder hinzuzufügen das importiert Module). Daher ist der Top-Down-Ansatz dort besonders bemerkenswert: Sie können problemlos mit den Funktionen der obersten Ebene beginnen, manchmal Stubs platzieren und das, was sie aufrufen und später verwenden, anhängen. Es ist sehr einfach, ein kleines Programm zu entwickeln - schreiben Sie ein Minimum an Code in den Editor und laden Sie ihn regelmäßig auf REPL hochund eine erfolgreiche Kompilierung zu erreichen, dann versuchen Sie, die implementierten Funktionen aufzurufen, die schleichenden Fehler zu bearbeiten und die erforderlichen Stubs zu implementieren usw., bis Sie mit dem Arbeitsergebnis zufrieden sind.

Ich werde die Haskell-Syntax, die Grundfunktionen und den ganzen Jazz nicht erklären, da der Artikel ohnehin detailliert und aufgedunsen war. Daher richtet es sich an eine Person, die bereits Erfahrung in der Sprache hat. Wenn dies nicht der Fall ist, beginnen Sie mit einer der oben genannten Einführungen. Stattdessen werde ich mich darauf konzentrieren, bestimmte Bibliotheken zu demonstrieren und zu erklären, wie die Gedanken, die während des eigentlichen Entwicklungsprozesses (den ich parallel zum Schreiben des Artikels geführt habe) auf dem Code beruhen. Aus dem gleichen Grund mag der Artikel wie ein Bewusstseinsstrom aussehen, und ich entschuldige mich im Voraus bei denen, die diesen Stil nicht mögen.

Minenfeld


Also fangen wir an. Ich glaube nicht, dass jemand die Minesweeper-Regeln erklären muss, daher werden wir sofort mit der Implementierung fortfahren und auf dem Weg werden Einschränkungen auftauchen. Das Spiel muss von einer vollwertigen Binärdatei gestartet werden. Das erste, was wir haben, ist ein Einstiegspunkt - wie in vielen anderen Sprachen ist dies eine Funktion main. Sie sollte ein erstes Spielfeld erstellen und darauf ein Spiel ausführen:
main :: IO ()
main = do
    let field = createField
    startGame field

Was macht createField? Schafft irgendwie ein Feld, aber wie, ist bisher nicht klar. Lassen Sie es zunächst eine feste Größe haben, die wir in Konstanten setzen:
fieldSize@(fieldWidth, fieldHeight) = (15, 15) :: (Int, Int)
mineCount = 40 :: Int
createField :: Field
createField = undefined
startGame :: Field -> IO ()
startGame = undefined

Wir werden einen undefinedStub am Ort der Implementierung hinterlassen , da noch nicht klar ist, was mit diesen Parametern geschehen soll, und wir werden darüber nachdenken, was Field ist. Das Feld in Minesweeper ist eine zweidimensionale Anordnung von Zellen. Arrays in der Haskell existieren natürlich, aber mit veränderlichen Arrays zu arbeiten (und sie werden während des Spiels ständig auf das Feld klicken) ist nicht sehr praktisch. Um die Wandlungsfähigkeit der E / A- und ST-Monaden nicht zu beeinträchtigen, verwenden wir eine einfache dauerhafte Alternative - ein Wörterbuch, in dem die Schlüssel die Positionen der Zellen und die Werte ihre Zustände sind:
type Field = Map Cell CellState --не забудем дописать вначале import Data.Map
type Cell = (Int, Int)

Welche Bedingungen kann eine Zelle haben?
  • Für den Anfang kann eine Zelle offen oder geschlossen sein, und es kann eine Mine darauf sein.
  • Wenn es geöffnet ist, kann es entweder eine Nummer mit der Anzahl der verminteten Nachbarn oder eine detonierte Bombe haben, wenn der Pionier versäumt hat.
  • Wenn es geschlossen ist, sollte der Pionier in der Lage sein, das Kontrollkästchen zu aktivieren oder zu deaktivieren.

Alle diese Zustände müssen sich gegenseitig ausschließen: Sie können beispielsweise nicht das offene Kästchen markieren oder eine Zahl in das geschlossene Kästchen zeichnen. Ein bestimmter algebraischer Typ bittet, dessen Konstruktoren möglichen Zuständen entsprechen:
data CellState = Closed {hasMine, hasFlag :: Bool} --Закрыта; параметры — стоят ли флаг и мина
               | Opened (Maybe Int) --Открыта; параметр — сколько у неё опасных соседей (и Nothing, если мина в ней самой)

Typ fiel ziemlich ungeschickt aus, alles stapelt sich darin. Diese Option ist auch durchaus machbar; aber versuche mehr zu denken und gehe, wenn möglich, den anderen Weg.
Beachten Sie, dass hier zwei Zellzustände gemischt sind:
  • unveränderlich, ursprünglich generiert, intern (unabhängig davon, ob es eine Mine gibt oder nicht);
  • veränderbare visuelle (offen oder nicht, ob es eine Flagge oder eine Ziffer gibt).

Lassen Sie uns versuchen, das Bildmaterial zu trennen und das Vorhandensein von Minen irgendwie getrennt zu speichern. In diesem Fall wird der geschlossene Zustand unnötig, da die leere Zelle einfach nicht im Wörterbuch gespeichert werden kann (übrigens müsste alles im Array gespeichert werden). Nun, korrigieren Sie das obige im Code:
data CellState = Opened Int --Открыта; параметр — циферка, которая будет отображаться
               | Mine --Подорвались; без параметров
               | Flag --Поставлен флажок

Alles ist einfach! Minimale Optionen, minimale verschachtelte Parameter. Und dann Fieldzu Beginn des Spiels - es ist einfach leer Map:
createField = Data.Map.empty

Das heißt, jetzt ist das Feld nur das, was auf dem Bildschirm sichtbar ist. Aber dann müssen Sie den internen Zustand irgendwie separat speichern - eine Reihe von Minen "unter" diesem Feld. Aber das ist noch einfacher: Minen werden einfach durch die Menge der Zellen bestimmt, auf denen sie stehen, und während des Spiels ändert sich dieser Wert nicht:
type Mines = Set Cell --не забудем import Data.Set

Lassen Sie uns überprüfen, ob bei der Kompilierung alles in Ordnung ist - da der Code voll von Deklarationen und fast keinen Definitionen ist, gibt es bislang keine Probleme.

Im Gegensatz zu Zellen kann der Startsatz der Minen überhaupt nicht leer sein. Da alle Haskell-Funktionen rein und deterministisch sind, müssen wir, um eine Zufallsmenge zu erstellen, entweder in der E / A-Monade arbeiten oder einen Pseudozufallsgenerator mitführen oder unser ganzes Leben lang auf demselben Feld spielen. Einerseits läuft das Spiel immer noch in IO und die zusätzliche Einschränkung behindert nicht sonderlich. Auf der anderen Seite wird eine allgemeinere und sauberere Lösung auch nicht schaden. Was ist besser ist eine offene Frage, so lassen Sie es eine zweite Option sein. Wir brauchen also einen Zufallsgenerator (in Haskell gehören sie zur Typenklasse RandomGen) und die Koordinate der ersten gedrückten Zelle, um nicht versehentlich beim ersten Zug zu untergraben:
createMines :: RandomGen g => g -> Cell -> Mines

Das gleichmäßige Auswählen von n zufälligen Feldzellen ist ein separates Problem. Um mich nicht zu stören, habe ich nichts Besseres gefunden, als alle möglichen Zellen zu mischen und die n ersten zu nehmen. Nun, entferne den Start von dort:
createMines g fst = Data.Set.fromList $ take mineCount $ shuffle g $
    [(i, j) | i <- [0 .. fieldWidth - 1]
            , j <- [0 .. fieldHeight - 1]
            , (i, j) /= fst]

Funktion shuffle, eine Liste im Standardpaket Mischen ist kein Compiler. Nun, der allwissende Geist namens Hoogle wird uns helfen . Fragen Sie, was er für das Schlüsselwort shuffle sagen kann.



Ja, es gibt eine ganze kleine Tüte mit Zufallsmischungen, die dieses Problem speziell löst. Wir sagen es:
$> cabal install random-shuffle

und finden Sie eine Funktion, die in erster Näherung genau das tut, was Sie brauchen:
shuffle' :: RandomGen gen => [a] -> Int -> gen -> [a]

Sie hat sogar eine ähnliche Signatur wie wir. Richtig, sie führt ein weiteres zusätzliches Argument an, das sich bei näherer Betrachtung als die Länge der sortierten Liste herausstellt. Warum ich es brauchte, habe ich immer noch nicht verstanden (vielleicht, um es nicht noch einmal zu zählen length, wenn es im Voraus bekannt ist), also machen wir einen Umschlag:
shuffle g l = shuffle' l (fieldWidth * fieldHeight - 1) g
-- import System.Random.Shuffle (shuffle'), чтобы не конфликтовать именами

Jetzt ist es sogar möglich, REPL zu kompilieren, auszuführen und zu testen:
ghci> do {g <- getStdGen; return $ createMines g (0, 0)}
fromList [(0,1),(1,10),(2,5),(2,7),(2,12),(2,14),(3,8),(4,10),(6,7),(6,10),(7,11),(7,12),(8,4),(8,12),(9,7),(9,12),(10,14),(12,0),(12,5),(13,8)]

Großartig. Jetzt können Sie zum Hauptmenü zurückkehren und das Spiel starten. Wie das Fenster angezeigt und darauf gezeichnet wird, hängt stark vom verwendeten Grafik-Framework ab. Ich werde Gloss verwenden , um Anwendungen mit einfachen zweidimensionalen Vektorgrafiken zu erstellen.
Die Bibliothek hat kürzlich die Namen einiger Standardfunktionen geringfügig geändert. Wenn Sie also nicht codieren möchten, stellen Sie sicher, dass Sie über die neueste Version verfügen.


Bringen Sie Glanz


Anstelle der klassischen Imperativfunktionen des Typs "Zeichne dies, dann zeichne sho", wie sie beispielsweise in Haskel-Bindungen an OpenGL, SDL und andere gängige Grafik-APIs zu sehen sind, verwendet Gloss die proprietäre FP-Idee von Kombinatoren und der Zusammensetzung von Funktionen. Was Gloss auf dem Bildschirm zeichnen kann, ist Picture, eine Art abstraktes Bild. Es verfügt über elementare Funktionen zum Erstellen von Bildern aus einfachen grafischen Grundelementen (wie Linien, Rechtecken und Bitmap-Bildern), Funktionen zum Verschieben und Skalieren von Bildern, zum Kombinieren mehrerer Bilder zu einem usw. usw. Das Ergebnis ist deklarativ Erstellen komplexer Szenen aus einfacheren (wieder „von oben nach unten“): Die im Fenster gezeichnete Szene ist ein Bild, das aus Bildern besteht, die aus konvertierten Bildern bestehen. bis ganz unten sind OpenGL-Primitive unter der Haube versteckt. Ein weiterer wichtiger Faktor ist, dass Gloss über ein kleines, aber sehr stolzes Subsystem für die Verarbeitung von Benutzereingabeereignissen verfügt (das es von konzeptionell ähnlichen, aber rein statischen Diagrammen unterscheidet), das für uns sehr nützlich ist. Sie können sich einfache Beispiele ansehendie offizielle Website der Bibliothek , und ich werde sofort alles Nötige in dem Artikel verwenden.
Um interaktive Szenen zu starten (die sich nicht nur rendern, sondern auch zeitlich ändern und auf externe Ereignisse reagieren können), verfügt Gloss über eine Wiedergabefunktion, die einen ungewöhnlich umständlichen 7-er Typ aufweist:
play :: Display -> Color -> Int -> world -> (world -> Picture) -> (Event -> world -> world) -> (Float -> world -> world) -> IO ()

Die ersten beiden Argumente sind trivial (Fenster- oder Vollbildoptionen und die Standardhintergrundfarbe). Und dann beginnt der Spaß.
Lassen Sie uns ein wenig untersuchen, wie dieses Prinzip funktioniert play. Es gibt eine Szene oder eine interne „Welt“, deren Zustand im Typwert gespeichert ist world- dies ist kein Typ, sondern eine Typvariable, daher ist sie playpolymorph und kann mit jedem von uns eingeführten Zustand arbeiten. Um mit dem Status zu arbeiten, werden drei Rückruffunktionen verwendet.
  • Wenn die Engine es zeichnen muss, ruft sie den fünften Parameter auf - eine Typfunktion world -> Picture, die den "Zustand der Welt" in ein Bild verwandelt (nennen wir es eine Schublade oder renderer).
  • Eine Anzahl von Malen pro Sekunde (angegeben durch den dritten Parameter), für die sich die Welt ändern kann, für die es ein siebtes Argument gibt, das die aktuelle Zeit und den alten Zustand annimmt und ein neues zurückgibt (nennen wir es einen Updater oder updater).
  • Und schließlich, um externe Ereignisse vom Benutzer zu verarbeiten, gibt es ein sechstes Argument, das das Ereignis, den alten Zustand, ebenfalls akzeptiert und den neuen Zustand zurückgibt (wir nennen es einen Handler oder handler).

Bitte beachten Sie, dass alle Funktionen außer den meisten sauber sind play. Übrigens wurde im Haskell selbst in der Vorkriegszeit ein ähnlicher Ansatz mit reinen Funktionen verwendet, die verschiedene Ereignisse handhaben (Sie können ihn hier lesen ).
Hinweis: Wenn Sie zusätzlich zu den Standardereignissen mit der Außenwelt reagieren müssen (z. B. Dateien hochladen), gibt es eine ähnliche Funktion, die in allen Handlern mit dem Status der Welt in der E / A-Monade arbeitet.

Versuchen wir den Zustand der Welt zu beschreiben. Es sollte mindestens ein Feld von Zellen und eine Reihe von Minen enthalten. Die Anzahl der Minen ist jedoch unbekannt, bis der Spieler den ersten Zug ausführt. Sie müssen also irgendwie eine verzögerte Initialisierung durchführen. Am einfachsten (und bei weitem nicht am besten) ist es, dem Staat die entsprechende Flagge und den Generator hinzuzufügen. Um die Verwendung von "einmaligen" Feldern zu vermeiden, habe ich mir einen kleinen Hack ausgedacht:
data GameState = GS
    { field :: Field
    , mines :: Either StdGen Mines
    }

Wenn dies minesbei uns Leftder erste Schritt ist, erzeugen wir daraus eine Menge, die zur Rightth wird.
Jetzt kannst du das Spiel starten:
import Graphics.Gloss.Interface.Pure.Game
<...>
startGame :: StdGen -> IO ()
startGame gen = play (InWindow windowSize (240, 160)) (greyN 0.25) 30 (initState gen) renderer handler updater
windowSize = both (* (round cellSize)) fieldSize
cellSize = 24
initState gen = GS createField (Left gen)
both f (a, b) = (f a, f b) --вспомогательная функция, которая ещё пригодится 

Das Spiel ist statisch, also macht updateres nichts. handlerbisher auch, aber undefinednicht einstellen, um eine erfolgreiche Anzeige des Fensters zu erreichen.
updater _ = id
handler _ = id

renderer Zeichnen wir zunächst ein ganzes Feld mit leeren weißen Zellen:
renderer _ = pictures [uncurry translate (cellToScreen (x, y)) $ color white $ rectangleSolid cellSize cellSize
    | x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]]
cellToScreen = both ((* cellSize) . fromIntegral)

Lass es uns machen.



Ups - das Feld hat eine Ecke verlassen. Es stellt sich heraus, dass Gloss als Standardkoordinatensystem das annimmt, was in der Mathematik akzeptiert wird, wobei der Punkt (0,0) die Mitte des Bildschirms ist und die Oy- Achse nach oben gerichtet ist. Während in 2D-Grafiken normalerweise (0,0) die obere linke Ecke ist und oy nach unten geht. Aus demselben Grund werden alle Grundelemente relativ zur Mitte und nicht zur Ecke gezeichnet. Wir werden uns etwas später darum kümmern, aber zuerst werden wir versuchen, einige Ereignisse zu verarbeiten.

Interaktiv


Ein Ereignis in Gloss ist ein algebraischer Typ, dessen Konstruktoren Varianten von Ereignissen sind: Drücken verschiedener Schaltflächen ( EventKey), Bewegen des Cursors ( EventMotion), Ändern der Fenstergröße ( EventResize), von denen jede einige andere spezifische Parameter enthält. Dank Pattern Matching und partiellen Funktionsdefinitionen in Form von Klauseln können Sie ziemlich knifflige Handler für verschiedene Sonderfälle organisieren, und es sieht OnClick(MouseEventArgs e)in einigen WinForms fast wie Ansichtsmethoden aus .

Uns interessiert vor allem die Reaktion auf die Maustasten. Wenn das Feld leer ist, wollten wir eine Reihe von Minen erzeugen:
handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS
    { mines = Left gen
    } = gs { mines = Right $ createMines gen cell } where
    cell = screenToCell mouse
screenToCell = both (round . (/ cellSize)) --тут обе координаты делятся на размер клетки и округляются, тем самым получается её индекс

Lass es uns machen. Bei der geringsten Bewegung stürzt das Spiel mit unvollständigen Mustern ab. Tatsächlich behandelt der Handler nur einen bestimmten Fall. Daher müssen Sie die allgemeinste Klausel für die Behandlung von Ereignissen belassen, die für uns nicht von Interesse sind:
handler _ gs = gs

Jetzt funktioniert das Spiel stabil, aber anscheinend hat sich nichts drastisch geändert, weil die Minen nicht sichtbar sind. Fügen Sie eine Zellsektion hinzu. In der Regel wird im ursprünglichen "Minesweeper" automatisch ein ganzer Bereich von Zellen mit Nullen geöffnet, aber zunächst einmal (denken Sie daran - halten Sie es einfach!) Werden wir nur eine Zelle verarbeiten:
handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS
    { field = field
    , mines = Right mines
    } = gs { field = Data.Map.insert cell opened field } where
    cell@(cx, cy) = screenToCell mouse
    opened = if cell `Data.Set.member` mines --проверяем, попались ли на мину
             then Mine
             else Opened neighbours
    --Вычисляем число соседей: это все клетки, расстояние до которых от нажатой по обеим осям не более 1
    neighbours = length [ () | i <- [-1 .. 1], j <- [-1 .. 1]
                             , (i, j) /= (0, 0)
                             , (cx + i, cy + j) `Data.Set.member` mines]

Jetzt kann das Spiel gestartet und angeklickt werden, aber die Zellen werden immer noch nicht in irgendeiner Weise gezeichnet. Korrigieren Sie diesen Fehler. Zunächst zeichnen wir anstelle der geöffneten Zellen eine Figur (oder @, wenn es eine Bombe gibt):
renderer GS { field = field } = pictures $ [uncurry translate (cellToScreen (x, y)) $ drawCell x y | x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]] where
    drawCell x y = case Data.Map.lookup (x, y) field of
        Nothing -> color white $ rectangleSolid cellSize cellSize --клетка пустая
        Just Mine       -> pictures [ color red $ rectangleSolid cellSize cellSize
                                    , scale 0.15 0.15 $ color black $ text "@"
                                    ]
        Just (Opened n) -> pictures [ color green $ rectangleSolid cellSize cellSize
                                    , scale 0.15 0.15 $ color black $ text $ show n
                                    ]

Lassen Sie mich daran erinnern, dass der Kombinator pictureseine Liste mit mehreren "Bildern" zu einem zusammenfügt, während die Zeichnung von links nach rechts verläuft, dh die Elemente im Ende der Liste werden über den Elementen gezeichnet, die zuerst angezeigt werden.

Es ist auch erwähnenswert, dass die Standard-Vektorschrift in Gloss ehrlich gesagt vyrviglazny und zu groß ist (so dass Sie es mit reduzieren müssen scale), so dass Sie es so bald wie möglich durch Bitmap-Bilder ersetzen sollten. Aber fürs Erste werden wir es verschieben und zuerst den Rest der Spiellogik machen, um endlich diese veränderten Zustände der Feldzellen zu sehen. Aktivieren Sie zuerst die Kontrollkästchen und fügen Sie mit der rechten Maustaste eine Klausel hinzu:
handler (EventKey (MouseButton RightButton) Down _ mouse) gs@GS
    { field = field
    } = case Data.Map.lookup coord field of
        Nothing -> gs { field = Data.Map.insert coord Flag field }
        Just Flag -> gs { field = Data.Map.delete coord field }
        _ -> gs
        where coord = screenToCell mouse

und entsprechend deren Darstellung:
    drawCell x y = case M.lookup (x, y) field of
        <...>
        Just Flag       -> pictures [ color yellow $ rectangleSolid cellSize cellSize
                                    , scale 0.15 0.15 $ color black $ text "?"
                                    ]

Duplex mit der Schaffung von Zellen wäre eine Umgestaltung wert, aber ich überlasse dies dem Wahlfach.



Die Zellen werden geöffnet, aber die Beschriftungen werden auch verschoben. Sie werden mit einer Hilfsfunktion verschoben, die die Beschriftung erstellt:
    label = translate (-5) (-5) . scale 0.15 0.15 . color black . text

Außerdem verschwand das Gitter - weil die gefüllten geometrischen Formen keinen Rahmen haben. Sie müssen das Rendering separat und über den Zellen hinzufügen, sonst überlappen sie sich.
renderer GS { field = field } = pictures $ cells ++ grid where
    grid = [uncurry translate (cellToScreen (x, y)) $ color black $ rectangleWire cellSize cellSize | x <- [0 .. fieldWidth - 1], y <- [0 .. fieldHeight - 1]]

Projektionen koordinieren


Jetzt lohnt es sich, die Koordinaten zu korrigieren. Natürlich können Sie sie manuell von einem System auf ein anderes übertragen (und umgekehrt - weil die in den Ereignissen empfangenen Mauskoordinaten nicht an das Fenster gebunden sind, sondern an das absolute Koordinatensystem der Szene, was aus Gewohnheit verwirrend sein kann), aber das ist anstrengend. Glücklicherweise enthält Gloss einen Mechanismus , dies zu tun für uns - Projektionen (Ansichtsfenster). Eine Projektion ist eine Transformation, bei der das Fenster in der Ebene der Szene angezeigt wird und nicht nur verschoben, sondern auch skaliert und sogar gedreht werden kann. Damit das gesamte Feld auf den Bildschirm passt, sollte unsere Projektion einfach auf die Hälfte des Felds plus der Hälfte der Zelle verschoben werden (da die Zelle auch von der Mitte und nicht von der Ecke aus gezeichnet wird):
viewPort = ViewPort (both (negate . (/ 2) . (subtract cellSize)) $ cellToScreen fieldSize) 0 1 
--последние два параметра – это поворот и коэффициент масштабирования

Mit applyViewPortToPictureihr können Sie die Projektion auf jedes Bild anwenden und es auf die richtige Weise transformieren. Und für die inverse Transformation (vom Koordinatensystem des Bildes zum Koordinatensystem der Projektion), die zur Bearbeitung der Position des Cursors benötigt wird, ist invertViewPort. Korrigieren Sie unseren Code entsprechend:
screenToCell = both (round . (/ cellSize)) . invertViewPort viewPort
renderer GS { field = field } = applyViewPortToPicture viewPort <...>




Voila! Jetzt wird das Fenster so gezeichnet, wie es sollte, und Sie können sogar darin spielen. Zusätzlich bietet Gloss in Verbindung mit Projektionen sehr einfache Tools zum Scrollen und Skalieren des Bildschirms, mit denen Sie beispielsweise ein Feld mit 1000 x 1000 Zellen erstellen und wie in Karten navigieren können.

Sie können jedoch immer noch nicht verlieren - eine Minenexplosion hat keine Auswirkungen auf irgendetwas. Daher müssen Sie dem Spielstatus eine Markierung hinzufügen und den Ereignishandler einchecken:
data GameState = GS
    { field    :: Field
    , mines    :: Either StdGen Mines
    , gameOver :: Bool --На сладкое — сделать enum-ADT с состояниями "в процессе", "проиграли", "выиграли"
    }
<...>
handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS
    { field = field
    , mines = Right mines
    , gameOver = False
    } = gs
    { field = Data.Map.insert cell opened field
    , gameOver = exploded
    } where
    cell@(cx, cy) = screenToCell mouse
    (opened, exploded) =
        if cell `Data.Set.member` mines --проверяем, попались ли на мину
            then (Mine, True)
            else (Opened neighbours, False)

Jetzt können Sie im Spiel nach einem unvorsichtigen Schritt keinen weiteren machen.

Ein bisschen Bequemlichkeit


Das Interessanteste bleibt - das rekursive Öffnen eines ganzen Bereichs aus leeren Zellen. Machen wir eine Art Tiefensuche - simulieren wir das Stöbern in benachbarte Zellen, wenn diese definitiv keine Minen enthalten:
handler (EventKey (MouseButton LeftButton) Down _ mouse) gs@GS
    { field = field
    , mines = Right mines
    , gameOver = False
    } = gs
    { field = newField
    , gameOver = exploded
    } where
    newField = click cell field
    exploded = case Data.Map.lookup cell newField of --Проигрыш, если последняя вскрытая клетка - мина
        Just Mine -> True
        _         -> False
    cell@(cx, cy) = screenToCell mouse
    click :: Cell -> Field -> Field
    click c@(cx, cy) f
        | c `Data.Map.member` f = f --повторно клетку не обрабатываем
        | c `Data.Set.member` mines = put Mine --попались на мину
        | otherwise = let nf = put (Opened neighbours) in
            if neighbours == 0
                then Prelude.foldr click nf neighbourCells --Обойдём соседей
                else nf
        where
            put state = Data.Map.insert c state f
            --Вычисляем число соседей: это все клетки, расстояние до которых от нажатой по каждой из осей не более 1
            neighbourCells =  [ (cx + i, cy + j) | i <- [-1 .. 1], j <- [-1 .. 1] ]
            neighbours = length $ Prelude.filter (`Data.Set.member` mines) neighbourCells

Wir fangen an, stupsen und ... das Spiel friert ein. Wie denn so kompiliert! Leider ist auch Haskell nicht in der Lage, alle Laufzeitfehler abzusichern. Insbesondere tritt ein solcher Stillstand häufig auf, wenn ein Programm in eine Endlosschleife oder Rekursion eintritt. Und sicher - wir überprüfen nicht den Weg aus dem Feld, also wird die Suche nach Nachbarn versuchen, die gesamte unendliche Ebene zu umgehen. Einschränkungen hinzufügen:
            neighbourCells = [ (i, j) | i <- [cx - 1 .. cx + 1], j <- [cy - 1 .. cy + 1]
                             , 0 <= i && i < fieldWidth
                             , 0 <= j && j < fieldHeight
                             ] --Жаль, нельзя написать 0 <= i < fieldWidth

Jetzt können Sie endlich das Ergebnis genießen:



Nachwort


Natürlich ist der Spielraum für Verbesserungen immer noch groß - fügen Sie eine magische Kombination von zwei Maustasten hinzu (um die Nachbarn einer Zelle zu enthüllen, in der bereits alle Markierungen vorhanden sind), befestigen Sie die normalen Grafiken (derselbe Glanz kann Bitmaps laden), und passen Sie die Feldgröße und die Anzahl der Minen an (Weiterleitung an Trotzdem hoffe ich, dass ich Ihnen zeigen konnte, dass Sie keine Angst vor dem Haskell haben müssen, und dass es eine erhebende Aufgabe ist, ein Prototyp für etwas Einfaches und Interaktives in hundert Zeilen zu erstellen .

Vollständiger Code auf Pastebin .

Vielen Dank für Ihre Aufmerksamkeit!

Jetzt auch beliebt: