Skip to content

Latest commit

 

History

History
323 lines (226 loc) · 11.5 KB

04data.md

File metadata and controls

323 lines (226 loc) · 11.5 KB

Typy danych

Przypomnijmy sobie funkcję drawTile :: Integer -> Picture tworzącą obraz pola na podstawie numeru jego rodzaju. Kod byłby czytelniejszy i mniej podatny na błędy, gdyby zamiast numerów stosowac nazwy symboliczne. W innych językach stosujemy konstrukcje takie jak #define albo enum, w Haskellu zaś konstrukcję data:

data Tile = Wall | Ground | Storage | Box | Blank

W ogólności data daje o wiele większe możliwosci, ale w swojej najprostszej postaci pozwala zdeinifować typ poprzez wyliczenie konstruktorów jego wartości. Wartościami typu Tile są dokładnie te wyliczone konstruktory; nie ma problemu jak funkcja drawTile ma zachowac się np. dla wartości -1.

NB nazwy konstruktorów powinny zaczynać się od wielkiej litery (bądź dwukropka dla nazw infiksowych, złożonych z symboli)

Rozpoznawanie konstruktorów odbywa się zwykle przez dopasowanie wzorca, np. (otwórz w CodeWorld)

drawTile :: Tile -> Picture
drawTile Wall    = wall
drawTile Ground  = ground
drawTile Storage = storage
drawTile Box     = box
drawTile Blank   = blank

maze :: Int -> Int -> Tile
maze x y
  | abs x > 4  || abs y > 4  = Blank
  | abs x == 4 || abs y == 4 = Wall
  | x ==  2 && y <= 0        = Wall
  | x ==  3 && y <= 0        = Storage
  | x >= -2 && y == 0        = Box
  | otherwise                = Ground

Zauważmy, że teraz również sygnatury typów stają się bardziej czytelne i pomocne.

📝 Przerób swój kod tak aby używał nowego typu Tile zamiast kodowania typów pól liczbami.

Bool

Z jednym wyliczeniowym typem danych już się zetknęliśmy: Bool. Nie jest on "magiczny", ale zdefiniowany jako

data Bool = False | True

Podobnie operatory takie jak (&&) nie są wbudowane, ale każdy mógłby je zdefiniować (spróbuj!).

Więcej typów dla gry Sokoban

Naszym celem jest rozszerzenie animacji o interakcję z użytkownikiem. Zacznijmy od potrzebnych typów.

Piewszą rzecza, którą moglibyśmy chcieć zrobić jest przesuwanie planszy (np. gdy jest większa niż nasze okno). Potem może chcielibyśmy przesuwać gracza po planszy. Potrzebujemy typu reprezentującego kierunki:

data Direction = R | U | L | D

Potrzebujemy też typu reprezentującego pozycję. Tutaj typ wyliczeniowy już nie wystarczy; musimy też przechowywać wartości współrzędnych. Możemy to osiągnąć przez konstruktory z parametrami, np.

data Coord = C Int Int

(moglibyśmy użyć też pary (Int, Int), ale dedykowane typy dają lepsze komunikaty o błędach).

Konstruktor C (poza tym, że może wystapić we wzorcach) zachowuje się jak funkcja typu Int -> Int -> Coord, oto przykład:

initialCoord :: Coord
initialCoord = C 0 0

Wyłuskiwanie składowych typu Coord możemy uzyskać przy pomocy dopasowania wzorca. Na przykład możemy potrzebować funkcji przesuwającej obraz o podane współrzędne:

atCoord :: Coord -> Picture -> Picture
atCoord (C x y) pic = translated (fromIntegral x) (fromIntegral y) pic

translated bierze argumenty typu Double, dlatego musimy uzyć fromIntegral.

📝 Napisz funkcję adjacentCoord :: Direction -> Coord -> Coord dającą współrzędne przesuniete o 1 w podanym kierunku.

Możesz ją przetestować w ghci. Aby móc wypisywac elementy swojego typu, warto dodać do jego definicji klauzulę deriving Show, np.

data Coord = C Int Int deriving Show

Jeśli chcesz skłonić CodeWorld aby coś wypisał możesz użyć funkcji print w main, np.

main = print 42

albo (jeśli zdefiniowałeś Coord z klauzulą deriving Show)

main = print (C 1 2)

Innym sposobem przetestowania jest rysowanie naszego poziomu w różnych miejscach, np.

someCoord :: Coord
someCoord = adjacentCoord U (adjacentCoord U (adjacentCoord L initialCoord))

main = drawingOf (atCoord someCoord pictureOfMaze)

📝 napisz funkcję moveCoords :: [Direction] -> Coord -> Coord taką aby powyższy przykład dało się zapisać krócej jako

someCoord = moveCoords [U, U, L] initialCoord

Terminologia

  • typ, w którym żaden z konstruktorów nie ma argumentów nazywamy typem wyliczeniowym (ang. enumeration type);
  • typ o jednym konstruktorze nazywamy typem produktowym (ang. product type) - jest izomorficzny z produktem argumentów konstruktora, np Coord = C Int Int ~ (Int, Int)
  • typ o więcej niż jednym konstruktorze nazywamy typem sumarycznym (ang. sum type) jest izomorficzny z sumą rozłączną odpowiednich typów
  • typ bez konstruktorów (tak, to możliwe i czasem użyteczne!) nazywamy typem pustym (ang. empty type, nie mylić z ())

Etykiety pól

Spójrzmy na definicje

    data Point = Pt Float Float
    pointx                  :: Point -> Float
    pointx (Pt x _)         =  x
    pointy ...

Definicja pointx jest “oczywista”; możemy krócej:

    data Point = Pt {pointx, pointy :: Float}

W jednej linii definiujemy typ Point, konstruktor Pt oraz funkcje pointxpointy.

Na przyklad zamiast

-- typ świata  gracz kierunek  boxy    mapa    xDim      yDim      lvlNum  movNum
data State = S Coord Direction [Coord] MazeMap [Integer] [Integer] Integer Integer

można

data State = S {
  stPlayer :: Pos,
  stDir    :: Direction,
  stBoxes  :: [Coord],
  stMap    :: MazeMap,
  stXdim   :: [Integer],
  stYdim   :: [Integer],
  stLevel  :: Integer,
  stMove   :: Integer
}

Oprócz wartościowej dokumentacji uzyskujemy też funkcje pozwalające wyłuskiwać poszczególne składowe, np.

stPlayer :: State -> Pos

oraz możliwość łatwiejszego zapisywania modyfikacji składowych, np. zamiast

foo s@(S c _ b mm xd yd n mn) = S c D b mm xd yd n (mn+1)

wystarczy

foo s = s { stDir = D, stMove = stMove s + 1  }

Czysta interakcja

Pora uczynić naszą grę interaktywną. Chcemy aby po uruchomieniu programu, poziom był wyśrodkowany, a następnie, aby użytkownik mógł przesuwać go przy pomocy klawiszy strzałek.

Jak możemy modelować interakcję w świecie bez efektów ubocznych? Podobnie jak to uczyniliśmy dla animacji: animacja jest funkcją z czasu w obraz. Program reagujący na zdarzenia możemy przedstawić jako funkcję, która mając bieżący stan i zdarzenie, oblicza nowy stan:

activityOf :: world ->
              (Event -> world -> world) ->
              (world -> Picture) ->
              IO ()

Występujacy w tym typie typ świata world jest zmienną typową - możemy użyć w jej miejsce dowolnego typu (szerzej powiemy sobie o tym później. Jeżeli chcemy tyliko przesuwać poziom, na początek możemy użyć Coord.

Funkcja activityOf bierze 3 argumenty:

  1. Początkowy stan typu world.
  2. Funkcję opisującą zmiany stanu w reakcji na zdarzenia, typu Event -> world -> world.
  3. Funkcję przedstawiającą stan jako obraz.

Takie podejście jest zbliżony do paradygmatu Model-View-Controller, ale nie używa efektów ubocznych, a tylko czystych funkcji.

Prosta próba użycia activityOf może wyglądać np. tak (zobacz na CodeWorld):

main = activityOf initial handleEvent drawState

handleEvent :: Event -> Coord -> Coord
handleEvent e c = adjacentCoord U c

drawState :: Coord -> Picture
drawState c = atCoord c pictureOfMaze

Nawiasem mówiąc, w starszych wersjach CodeWorld występowała funkcja interactionOf, biorąca jako dodatkowy parametr funkcję opisującą zmiany stanu z upływem czasu, typu Double -> world -> world. Obecnie upływ czasu traktowany jest jako jedno ze zdarzeń.

To ...coś robi. Ale gdy tylko najedziemy muszą na obraz, on ucieka ... Dlaczego? Przy każdym zdarzeniu obraz przesuwa się do góry. A ruchy myszy i upływ czasu też są zdarzeniami.

Zdarzenia

Przyjrzyjmy się zatem typowi zdarzeń Event. Według dokumentacji jest on zdefiniowany jako data, mniej więcej tak:

data Event = KeyPress Text
           | KeyRelease Text
           | PointerPress Point
           | PointerRelease Point
           | PointerMovement Point
           | TimePassing Double
           | TextEntry Text  -- syntetyczne zdarzenie wprowadzenia znaku, np "Ą"

W tym momencie interesują nas zdarzenia KeyPress. Spróbujmy je obsłużyć:

handleEvent :: Event -> Coord -> Coord
handleEvent (KeyPress key) c
    | key == "Right" = adjacentCoord R c
    | key == "Up"    = adjacentCoord U c
    | key == "Left"  = adjacentCoord L c
    | key == "Down"  = adjacentCoord D c
handleEvent _ c      = c

📝 dodaj obsługę klawiszy WASD

Uwaga: Aby używać stałych typu Text musimy w pierwszej linii programu dodać zaklęcie (pragmę)

{-# LANGUAGE OverloadedStrings #-}

Sekwencja {- ... -} oznacza komentarz blokowy. Sekwencja {-# ... #-} oznacza pragmę, czyli wskazówkę dla kompilatora. W tym wypadku pragma LANGUAGE OverloadedStrings oznacza rozszerzenie języka, w którym literały napisowe są przeciążone i (podobnie jak literały liczbowe) dopasowują się do oczekiwanego typu - domyślnie są typu String, ale tutaj chcemy ich użyć w typie Text.

📝 Sokoban 2

Etap 1: ruchomy gracz

Stwórz definicję player1 :: Picture reprezentującą figurkę gracza.

Zdefiniuj walk1 :: IO () wykorzystujące activityOf aby:

  • postać gracza była rysowana na obrazie poziomu;
  • początkowa pozycja gracza wypadała na pustym polu (można uzyć ustalonych współrzędnych, nie trzeba szukać pustego pola w programie);
  • klawisze strzałek przesuwały obraz gracza (obraz poziomu ma pozostać nieruchomy);
  • gracz przesuwał się tylko na pola Ground lub Storage (nie wchodzimy na ściany ani pudła).

Zwróć uwagę na kolejnosc elementów w & bądź pictures:

design, square, circ :: Picture
design =  pictures [square, circ]
circ = colored red (solidCircle 1)
square = colored black (solidRectangle 1 1)

Etap 2: gracz skierowany

Chcemy aby postać gracza patrzyła w stronę, w którą się porusza. Zdefiniuj funkcję player2 :: Direction -> Picture dającą figurkę gracza skierowaną w odpowiednią stronę.

Rozszerz kod z Etapu 1, definiując walk2 :: IO() tak, aby figurka gracze była wyświetlana odpowiednio do kierunków ruchu.

👉 Wskazówka: pomyśl najpierw o typach (np. stanu świata), potem o implementacji.

Uwaga: upewnij się, że po Twoich modyfikacjach funkcja walk1 nadal działa.

Etap3: reset

W trakcie gry przydatna będzie mozliwość rozpoczęcia poziomu od początku. Ta funkcjonalność jest w gruncie rzeczy niezależna od gry, zatem zaimplementujmy ją ogólnie. Napisz funkcję

resettableActivityOf ::
    world ->
    (Event -> world -> world) ->
    (world -> Picture) ->
    IO ()

która zasadniczo będzie działać jak activityOf, ale dla zdarzenie odpowiadające naciśnięciu klawisza Esc nie jest przekazywane dalej, ale powoduje powrót stanu gry do stanu początkowego.

Zastanów się co powinno się dziać dla zdarzenia odpowiadającego puszczeniu klawisza Esc i opisz swój wybór w komentarzu.

Zdefiniuj walk3 :: IO () jako wariant walk2 używający resettableActivityOf.

Termin: 3.11.2022 godzina 18:00

Oddawanie przez GitHub Classroom: https://classroom.github.com/a/gVrstMmS