diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 948b61c..74c9901 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,11 @@ jobs: with: ghc-version: ${{ matrix.ghc }} + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev freeglut3-dev + - name: Configure the build run: | cabal configure --enable-tests --enable-benchmarks --disable-documentation diff --git a/TODO.md b/TODO.md index 6d90b38..d885871 100644 --- a/TODO.md +++ b/TODO.md @@ -8,3 +8,8 @@ * Check cabal outdated * Check in diffs between solution and problem and make sure it is stable (such that every fix in a solution gets propagated to the problem and vice versa) + +## UI + +* square that rotates with time +* paint, clear, paintAll: Maybe more complicated program with several paint calls, picture builds up if you forget to call clear diff --git a/diffs/koans/ui/1-gloss/1-circle/diff.txt b/diffs/koans/ui/1-gloss/1-circle/diff.txt new file mode 100644 index 0000000..bfc8741 --- /dev/null +++ b/diffs/koans/ui/1-gloss/1-circle/diff.txt @@ -0,0 +1,2 @@ +< constMCl (paintAllIO _) -- paintAllIO clears the drawing canvas and draws the given image +> constMCl (paintAllIO (circleSolid 10)) -- paintAllIO clears the drawing canvas and draws the given image diff --git a/diffs/koans/ui/1-gloss/2-move/diff.txt b/diffs/koans/ui/1-gloss/2-move/diff.txt new file mode 100644 index 0000000..fff3e20 --- /dev/null +++ b/diffs/koans/ui/1-gloss/2-move/diff.txt @@ -0,0 +1,2 @@ +< rhine = sinceInitS >-> arrMCl (\t -> translate 0 (10 * t) $ paintAllIO $ circleSolid 10) @@ GlossSimClockIO +> rhine = sinceInitS >-> arrMCl (\t -> paintAllIO $ translate 0 (10 * t) $ circleSolid 10) @@ GlossSimClockIO diff --git a/diffs/koans/ui/1-gloss/3-modularize/diff.txt b/diffs/koans/ui/1-gloss/3-modularize/diff.txt new file mode 100644 index 0000000..d54468d --- /dev/null +++ b/diffs/koans/ui/1-gloss/3-modularize/diff.txt @@ -0,0 +1,15 @@ +> -- base +> import GHC.Float (double2Float) +> +< movingCircle = sinceInitS >-> arr (\t -> translate 0 (10 * t) $ circleSolid 10) -- realToFrac works as well! +> movingCircle = sinceInitS >-> arr (\t -> translate 0 (10 * double2Float t) $ circleSolid 10) -- realToFrac works as well! +< _ (Millisecond 500) +> GlossConcTClock IO (Millisecond 500) +< gameClock = _ waitClock +> gameClock = glossConcTClock waitClock +< _ _ GlossSimClockIO +> GlossClockUTC IO GlossSimClockIO +< visualizationClock = _ GlossSimClockIO +> visualizationClock = glossClockUTC GlossSimClockIO +< rhine = movingCircle @@ gameClock >-- _ blank --> visualize @@ visualizationClock +> rhine = movingCircle @@ gameClock >-- keepLast blank --> visualize @@ visualizationClock diff --git a/diffs/koans/ui/1-gloss/4-user-input/diff.txt b/diffs/koans/ui/1-gloss/4-user-input/diff.txt new file mode 100644 index 0000000..26547d5 --- /dev/null +++ b/diffs/koans/ui/1-gloss/4-user-input/diff.txt @@ -0,0 +1,10 @@ +< _ -> _ +> (EventKey (SpecialKey KeyRight) Down _ _) -> Just TurnRight +> (EventKey (SpecialKey KeyLeft) Down _ _) -> Just TurnLeft +> _ -> Nothing +< let newDirection = _ +< newPosition = _ +< in Result _ _ +> let newDirection = maybe direction (`changeDirection` direction) turnMaybe +> newPosition = stepPosition newDirection position +> in Result (newPosition, newDirection) newPosition diff --git a/diffs/koans/ui/1-gloss/5-randomness/diff.txt b/diffs/koans/ui/1-gloss/5-randomness/diff.txt new file mode 100644 index 0000000..7327a3c --- /dev/null +++ b/diffs/koans/ui/1-gloss/5-randomness/diff.txt @@ -0,0 +1,9 @@ +< arr (Just <<< Apple) <<< _ -< (Position (-boardSize) (-boardSize), Position boardSize boardSize) +> arr (Just <<< Apple) <<< getRandomRS -< (Position (-boardSize) (-boardSize), Position boardSize boardSize) +< addedApple <- _ -< () +> addedApple <- evalRandIOS' newApple -< () +< game = _ +> game = feedback DontEat $ proc (turn, eat) -> do +> snake <- snakeSF -< (turn, eat) +> (apples, eatNext) <- applesSF -< head $ body snake +> returnA -< ((snake, apples), eatNext) diff --git a/diffs/koans/ui/1-gloss/6-control-flow/diff.txt b/diffs/koans/ui/1-gloss/6-control-flow/diff.txt new file mode 100644 index 0000000..420a2b1 --- /dev/null +++ b/diffs/koans/ui/1-gloss/6-control-flow/diff.txt @@ -0,0 +1,3 @@ +< _ +> try $ liftClSF snakeAndApples >>> throwOnCond (fst >>> illegal) () >>> arr Just +> safe $ pure Nothing diff --git a/generic/test-gloss/TestGloss.hs b/generic/test-gloss/TestGloss.hs new file mode 100644 index 0000000..bf78449 --- /dev/null +++ b/generic/test-gloss/TestGloss.hs @@ -0,0 +1,50 @@ +module TestGloss where + +-- base +import Control.Concurrent +import Control.Monad +import Data.IORef +import System.Exit + +-- rhine-gloss +import FRP.Rhine.Gloss + +expectPic :: Picture -> [Picture] -> IO () +expectPic received expected = expectPics [received] [expected] + +expectPics :: [Picture] -> [[Picture]] -> IO () +expectPics receiveds expecteds = do + forM_ (zip receiveds expecteds) $ \(received, expected) -> do + let flattened = flattenPictures received + when (flattened /= expected) $ do + putStrLn $ "Expected: " ++ show expected + putStrLn $ "Received: " ++ show flattened + exitFailure + putStrLn "Well done!" + +flattenPictures :: Picture -> [Picture] +flattenPictures (Pictures ps) = ps >>= flattenPictures +flattenPictures Blank = [] +flattenPictures picture = [picture] + +stepGlossRhine :: (Clock GlossConc cl, Time cl ~ Time (Out cl), Time cl ~ Time (In cl), GetClockProxy cl) => Rhine GlossConc cl () () -> [Float] -> IO [Picture] +stepGlossRhine rhine timestamps = stepGlossRhineWithInput rhine timestamps [] + +stepGlossRhineWithInput :: (Clock GlossConc cl, Time cl ~ Time (Out cl), Time cl ~ Time (In cl), GetClockProxy cl) => Rhine GlossConc cl () () -> [Float] -> [Event] -> IO [Picture] +stepGlossRhineWithInput rhine timestamps events = do + vars <- makeGlossEnv + void $ forkIO $ forM_ events $ putMVar $ eventVar vars + void $ forkIO $ runGlossConcT (flow rhine) vars + forM timestamps $ \timestamp -> do + putMVar (timeVar vars) timestamp + threadDelay 33333 + readIORef (picRef vars) + +specialKey :: SpecialKey -> Event +specialKey key = EventKey (SpecialKey key) Down (Modifiers Down Down Down) (0, 0) + +keyRight :: Event +keyRight = specialKey KeyRight + +keyLeft :: Event +keyLeft = specialKey KeyLeft diff --git a/hie.yaml b/hie.yaml index bb23042..05cc2b9 100644 --- a/hie.yaml +++ b/hie.yaml @@ -257,3 +257,138 @@ cradle: - path: "generic/reimport-main/Main.hs" component: "rhine-koans:test:basic-2-9-modularize-test" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:exe:ui-1-gloss-1-circle" + + - path: "generic/test-gloss" + component: "rhine-koans:test:ui-1-gloss-1-circle-test" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:test:ui-1-gloss-1-circle-test" + + - path: "koans/ui/1-gloss/1-circle/test/Test.hs" + component: "rhine-koans:test:ui-1-gloss-1-circle-test" + + - path: "koans/ui/1-gloss/1-circle" + component: "rhine-koans:test:ui-1-gloss-1-circle-test" + + - path: "koans/ui/1-gloss/1-circle/solution" + component: "rhine-koans:test:ui-1-gloss-1-circle-test" + + - path: "koans/ui/1-gloss/1-circle" + component: "rhine-koans:exe:ui-1-gloss-1-circle" + + - path: "koans/ui/1-gloss/1-circle/solution" + component: "rhine-koans:exe:ui-1-gloss-1-circle" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:exe:ui-1-gloss-2-move" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:test:ui-1-gloss-2-move-test" + + - path: "koans/ui/1-gloss/2-move/test/Test.hs" + component: "rhine-koans:test:ui-1-gloss-2-move-test" + + - path: "koans/ui/1-gloss/2-move" + component: "rhine-koans:test:ui-1-gloss-2-move-test" + + - path: "koans/ui/1-gloss/2-move/solution" + component: "rhine-koans:test:ui-1-gloss-2-move-test" + + - path: "koans/ui/1-gloss/2-move" + component: "rhine-koans:exe:ui-1-gloss-2-move" + + - path: "koans/ui/1-gloss/2-move/solution" + component: "rhine-koans:exe:ui-1-gloss-2-move" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:exe:ui-1-gloss-3-modularize" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:test:ui-1-gloss-3-modularize-test" + + - path: "koans/ui/1-gloss/3-modularize/test/Test.hs" + component: "rhine-koans:test:ui-1-gloss-3-modularize-test" + + - path: "koans/ui/1-gloss/3-modularize" + component: "rhine-koans:test:ui-1-gloss-3-modularize-test" + + - path: "koans/ui/1-gloss/3-modularize/solution" + component: "rhine-koans:test:ui-1-gloss-3-modularize-test" + + - path: "koans/ui/1-gloss/3-modularize" + component: "rhine-koans:exe:ui-1-gloss-3-modularize" + + - path: "koans/ui/1-gloss/3-modularize/solution" + component: "rhine-koans:exe:ui-1-gloss-3-modularize" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:exe:ui-1-gloss-4-user-input" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:test:ui-1-gloss-4-user-input-test" + + - path: "koans/ui/1-gloss/4-user-input/test/Test.hs" + component: "rhine-koans:test:ui-1-gloss-4-user-input-test" + + - path: "koans/ui/1-gloss/4-user-input" + component: "rhine-koans:test:ui-1-gloss-4-user-input-test" + + - path: "koans/ui/1-gloss/4-user-input/solution" + component: "rhine-koans:test:ui-1-gloss-4-user-input-test" + + - path: "koans/ui/1-gloss/4-user-input" + component: "rhine-koans:exe:ui-1-gloss-4-user-input" + + - path: "koans/ui/1-gloss/4-user-input/solution" + component: "rhine-koans:exe:ui-1-gloss-4-user-input" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:exe:ui-1-gloss-5-randomness" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:test:ui-1-gloss-5-randomness-test" + + - path: "koans/ui/1-gloss/5-randomness/test/Test.hs" + component: "rhine-koans:test:ui-1-gloss-5-randomness-test" + + - path: "koans/ui/1-gloss/5-randomness" + component: "rhine-koans:test:ui-1-gloss-5-randomness-test" + + - path: "koans/ui/1-gloss/5-randomness/solution" + component: "rhine-koans:test:ui-1-gloss-5-randomness-test" + + - path: "koans/ui/1-gloss/5-randomness" + component: "rhine-koans:exe:ui-1-gloss-5-randomness" + + - path: "koans/ui/1-gloss/5-randomness/solution" + component: "rhine-koans:exe:ui-1-gloss-5-randomness" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:exe:ui-1-gloss-6-control-flow" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:test:ui-1-gloss-6-control-flow-test" + + - path: "koans/ui/1-gloss/6-control-flow/test/Test.hs" + component: "rhine-koans:test:ui-1-gloss-6-control-flow-test" + + - path: "koans/ui/1-gloss/6-control-flow" + component: "rhine-koans:test:ui-1-gloss-6-control-flow-test" + + - path: "koans/ui/1-gloss/6-control-flow/solution" + component: "rhine-koans:test:ui-1-gloss-6-control-flow-test" + + - path: "koans/ui/1-gloss/6-control-flow" + component: "rhine-koans:exe:ui-1-gloss-6-control-flow" + + - path: "koans/ui/1-gloss/6-control-flow/solution" + component: "rhine-koans:exe:ui-1-gloss-6-control-flow" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:exe:ui-1-gloss-snake" + + - path: "generic/reimport-main/Main.hs" + component: "rhine-koans:test:ui-1-gloss-snake-test" diff --git a/koans/ui/1-gloss/1-circle/Koan.hs b/koans/ui/1-gloss/1-circle/Koan.hs new file mode 100644 index 0000000..eba0671 --- /dev/null +++ b/koans/ui/1-gloss/1-circle/Koan.hs @@ -0,0 +1,41 @@ +{- | Circle. + +Let's draw something! +Rhine connects to the famous gloss library for 2d graphics. +Have a look at https://hackage.haskell.org/package/gloss to learn more about it! + +The connection between Rhine and gloss is provided by the library https://hackage.haskell.org/package/rhine-gloss, +which encapsulates the effects of drawing pictures in gloss in a monad, 'GlossConcT', +and provides several clocks to interact with the gloss system. + +To warm up, let's just draw a circle. +-} +module Koan where + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +{- | The main 'Rhine' of this program. + + /--- We use effects in 'GlossConc' to draw images. + | + | /--- This clock ticks whenever an image is drawn on the screen by the gloss backend. + | | + v v +-} +rhine :: Rhine GlossConc GlossSimClockIO () () +-- Can you create a solid circle of radius 10 here? +-- Have a look at https://hackage.haskell.org/package/gloss/docs/Graphics-Gloss-Data-Picture.html for inspiration. +rhine = + constMCl (paintAllIO _) -- paintAllIO clears the drawing canvas and draws the given image + @@ GlossSimClockIO -- The singleton value of GlossSimClockIO. + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = + flowGlossIO -- This function can replace 'flow' when you're using the gloss backend. + defaultSettings -- Settings for the gloss window context such as size, title, and background colour. + rhine diff --git a/koans/ui/1-gloss/1-circle/solution/Koan.hs b/koans/ui/1-gloss/1-circle/solution/Koan.hs new file mode 100644 index 0000000..eba6788 --- /dev/null +++ b/koans/ui/1-gloss/1-circle/solution/Koan.hs @@ -0,0 +1,41 @@ +{- | Circle. + +Let's draw something! +Rhine connects to the famous gloss library for 2d graphics. +Have a look at https://hackage.haskell.org/package/gloss to learn more about it! + +The connection between Rhine and gloss is provided by the library https://hackage.haskell.org/package/rhine-gloss, +which encapsulates the effects of drawing pictures in gloss in a monad, 'GlossConcT', +and provides several clocks to interact with the gloss system. + +To warm up, let's just draw a circle. +-} +module Koan where + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +{- | The main 'Rhine' of this program. + + /--- We use effects in 'GlossConc' to draw images. + | + | /--- This clock ticks whenever an image is drawn on the screen by the gloss backend. + | | + v v +-} +rhine :: Rhine GlossConc GlossSimClockIO () () +-- Can you create a solid circle of radius 10 here? +-- Have a look at https://hackage.haskell.org/package/gloss/docs/Graphics-Gloss-Data-Picture.html for inspiration. +rhine = + constMCl (paintAllIO (circleSolid 10)) -- paintAllIO clears the drawing canvas and draws the given image + @@ GlossSimClockIO -- The singleton value of GlossSimClockIO. + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = + flowGlossIO -- This function can replace 'flow' when you're using the gloss backend. + defaultSettings -- Settings for the gloss window context such as size, title, and background colour. + rhine diff --git a/koans/ui/1-gloss/1-circle/test/Test.hs b/koans/ui/1-gloss/1-circle/test/Test.hs new file mode 100644 index 0000000..1f03e97 --- /dev/null +++ b/koans/ui/1-gloss/1-circle/test/Test.hs @@ -0,0 +1,15 @@ +module Main where + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- test-gloss +import TestGloss + +-- koan +import Koan (rhine) + +main :: IO () +main = do + [pic] <- stepGlossRhine rhine [1] + expectPic pic [circleSolid 10] diff --git a/koans/ui/1-gloss/2-move/Koan.hs b/koans/ui/1-gloss/2-move/Koan.hs new file mode 100644 index 0000000..5bf2788 --- /dev/null +++ b/koans/ui/1-gloss/2-move/Koan.hs @@ -0,0 +1,27 @@ +{- | Move. + +One central idea of Functional Reactive Animation (Conal Elliot & Paul Hudak, ICFP 1997) +is that an animation is a picture parametrised by time. +This idea is continued in Yampa and Rhine by providing knowledge of time as a builtin effect, +which can then be used to parametrise everything the program does. + +In Rhine, one way to access time is to use ['sinceInitS'](https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Util.html#v:sinceInitS), +which outputs the time since clock initialisation (which happens at the beginning of 'flow'). +When you use this time to translate the position of the circle, it will move! +-} +module Koan where + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- | The main 'Rhine' of this program. +rhine :: Rhine GlossConc GlossSimClockIO () () +-- Somehow the order of these functions is wrong. Can you fix it? +rhine = sinceInitS >-> arrMCl (\t -> translate 0 (10 * t) $ paintAllIO $ circleSolid 10) @@ GlossSimClockIO + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/2-move/solution/Koan.hs b/koans/ui/1-gloss/2-move/solution/Koan.hs new file mode 100644 index 0000000..da392bb --- /dev/null +++ b/koans/ui/1-gloss/2-move/solution/Koan.hs @@ -0,0 +1,27 @@ +{- | Move. + +One central idea of Functional Reactive Animation (Conal Elliot & Paul Hudak, ICFP 1997) +is that an animation is a picture parametrised by time. +This idea is continued in Yampa and Rhine by providing knowledge of time as a builtin effect, +which can then be used to parametrise everything the program does. + +In Rhine, one way to access time is to use ['sinceInitS'](https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Util.html#v:sinceInitS), +which outputs the time since clock initialisation (which happens at the beginning of 'flow'). +When you use this time to translate the position of the circle, it will move! +-} +module Koan where + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- | The main 'Rhine' of this program. +rhine :: Rhine GlossConc GlossSimClockIO () () +-- Somehow the order of these functions is wrong. Can you fix it? +rhine = sinceInitS >-> arrMCl (\t -> paintAllIO $ translate 0 (10 * t) $ circleSolid 10) @@ GlossSimClockIO + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/2-move/test/Test.hs b/koans/ui/1-gloss/2-move/test/Test.hs new file mode 100644 index 0000000..78b6773 --- /dev/null +++ b/koans/ui/1-gloss/2-move/test/Test.hs @@ -0,0 +1,15 @@ +module Main where + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- test-gloss +import TestGloss + +-- koan +import Koan (rhine) + +main :: IO () +main = do + pics <- stepGlossRhine rhine [0, 1] + expectPics pics [[translate 0 0 $ circleSolid 10], [translate 0 10 $ circleSolid 10]] diff --git a/koans/ui/1-gloss/3-modularize/Koan.hs b/koans/ui/1-gloss/3-modularize/Koan.hs new file mode 100644 index 0000000..f5d1c5c --- /dev/null +++ b/koans/ui/1-gloss/3-modularize/Koan.hs @@ -0,0 +1,84 @@ +{- | Modularize. + +To make a round-based game, we need to encode the rounds in some way. +The most natural way to do this in Rhine is to define a separate clock where each tick corresponds to one round! + +Let's do this here. +For the rest of this track, we will just assume that a round lasts half a second. +So we should use a @'Millisecond' 500@ clock! + +The devil is in the details, though. +We now have two different components, the game clock and the visualization clock. +But they run on different monads and time domains! +You will have to translate between them in order to make everything flow together. + +Some background on monads and time domains: +Have a look at https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-Clock.html#t:Clock. +* The type variable @m@ in @Clock m cl@ is the monad in which the clock takes side effects while ticking. + It also determines how multiple clocks are scheduled. + A clock can be tied to a particular monad, or be polymorphic in it. + You can also change the monad with a 'HoistClock' (https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-Clock.html#t:HoistClock). +* The associated type @Time cl@ is called the /time domain/. + It is the type of time stamps that the clock emits while ticking. + A clock always has one fixed time domain. + To change it, you need to create a new clock, by applying a clock rescaling: + https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-Clock.html#t:RescaledClock +-} +module Koan where + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- * Game logic + +{- | A circle that moves upwards by 10 pixels every second. + +Its type signature ensures that it will be run on the 'GameClock'. +-} +movingCircle :: ClSF GlossConc GameClock () Picture +-- The cryptic type error wants to tell us that the time since clock initialisation is in Double, but gloss expects a Float! +-- Can you convert one to the other? +movingCircle = sinceInitS >-> arr (\t -> translate 0 (10 * t) $ circleSolid 10) -- realToFrac works as well! + +-- | A clock that ticks at every round of the game. +type GameClock = + -- Actually we just want a Millisecond 500 clock, but that is in the 'IO' monad, + -- while the gloss backend expects a particular monad, 'GlossConc' or 'GlossConcT'. + -- Luckily there is also a utility to lift any 'IO' clock to it! + -- Have a look at https://hackage.haskell.org/package/rhine-gloss/docs/FRP-Rhine-Gloss-IO.html. + _ (Millisecond 500) + +gameClock :: GameClock +-- The clock type lifting function from above also has a corresponding value level function! +gameClock = _ waitClock + +-- * Visualization + +-- | Paint a gloss picture +visualize :: BehaviourF GlossConc UTCTime Picture () +visualize = arrMCl paintAllIO + +-- | Draw at every tick of the gloss backend +type VisualizationClock = + -- The gloss backend has the TimeDomain Float, + -- but we want to use UTCTime instead! + -- Again, in https://hackage.haskell.org/package/rhine-gloss/docs/FRP-Rhine-Gloss-IO.html + -- you will find a type operator that rescales a gloss clock to UTC. + _ _ GlossSimClockIO + +visualizationClock :: VisualizationClock +visualizationClock = _ GlossSimClockIO + +rhine :: Rhine GlossConc (GameClock `SequentialClock` VisualizationClock) () () +-- Find the right resampling buffer to transport the rendered image from the game clock to the visualization clock. +-- It should have two properties: +-- 1. It should always output the newest image. +-- 2. At startup, before the first round of the game has started, a blank image should be displayed. +rhine = movingCircle @@ gameClock >-- _ blank --> visualize @@ visualizationClock + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/3-modularize/solution/Koan.hs b/koans/ui/1-gloss/3-modularize/solution/Koan.hs new file mode 100644 index 0000000..3ce9e0d --- /dev/null +++ b/koans/ui/1-gloss/3-modularize/solution/Koan.hs @@ -0,0 +1,87 @@ +{- | Modularize. + +To make a round-based game, we need to encode the rounds in some way. +The most natural way to do this in Rhine is to define a separate clock where each tick corresponds to one round! + +Let's do this here. +For the rest of this track, we will just assume that a round lasts half a second. +So we should use a @'Millisecond' 500@ clock! + +The devil is in the details, though. +We now have two different components, the game clock and the visualization clock. +But they run on different monads and time domains! +You will have to translate between them in order to make everything flow together. + +Some background on monads and time domains: +Have a look at https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-Clock.html#t:Clock. +* The type variable @m@ in @Clock m cl@ is the monad in which the clock takes side effects while ticking. + It also determines how multiple clocks are scheduled. + A clock can be tied to a particular monad, or be polymorphic in it. + You can also change the monad with a 'HoistClock' (https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-Clock.html#t:HoistClock). +* The associated type @Time cl@ is called the /time domain/. + It is the type of time stamps that the clock emits while ticking. + A clock always has one fixed time domain. + To change it, you need to create a new clock, by applying a clock rescaling: + https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-Clock.html#t:RescaledClock +-} +module Koan where + +-- base +import GHC.Float (double2Float) + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- * Game logic + +{- | A circle that moves upwards by 10 pixels every second. + +Its type signature ensures that it will be run on the 'GameClock'. +-} +movingCircle :: ClSF GlossConc GameClock () Picture +-- The cryptic type error wants to tell us that the time since clock initialisation is in Double, but gloss expects a Float! +-- Can you convert one to the other? +movingCircle = sinceInitS >-> arr (\t -> translate 0 (10 * double2Float t) $ circleSolid 10) -- realToFrac works as well! + +-- | A clock that ticks at every round of the game. +type GameClock = + -- Actually we just want a Millisecond 500 clock, but that is in the 'IO' monad, + -- while the gloss backend expects a particular monad, 'GlossConc' or 'GlossConcT'. + -- Luckily there is also a utility to lift any 'IO' clock to it! + -- Have a look at https://hackage.haskell.org/package/rhine-gloss/docs/FRP-Rhine-Gloss-IO.html. + GlossConcTClock IO (Millisecond 500) + +gameClock :: GameClock +-- The clock type lifting function from above also has a corresponding value level function! +gameClock = glossConcTClock waitClock + +-- * Visualization + +-- | Paint a gloss picture +visualize :: BehaviourF GlossConc UTCTime Picture () +visualize = arrMCl paintAllIO + +-- | Draw at every tick of the gloss backend +type VisualizationClock = + -- The gloss backend has the TimeDomain Float, + -- but we want to use UTCTime instead! + -- Again, in https://hackage.haskell.org/package/rhine-gloss/docs/FRP-Rhine-Gloss-IO.html + -- you will find a type operator that rescales a gloss clock to UTC. + GlossClockUTC IO GlossSimClockIO + +visualizationClock :: VisualizationClock +visualizationClock = glossClockUTC GlossSimClockIO + +rhine :: Rhine GlossConc (GameClock `SequentialClock` VisualizationClock) () () +-- Find the right resampling buffer to transport the rendered image from the game clock to the visualization clock. +-- It should have two properties: +-- 1. It should always output the newest image. +-- 2. At startup, before the first round of the game has started, a blank image should be displayed. +rhine = movingCircle @@ gameClock >-- keepLast blank --> visualize @@ visualizationClock + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/3-modularize/test/Test.hs b/koans/ui/1-gloss/3-modularize/test/Test.hs new file mode 100644 index 0000000..85a44b3 --- /dev/null +++ b/koans/ui/1-gloss/3-modularize/test/Test.hs @@ -0,0 +1,27 @@ +module Main where + +-- base +import Control.Monad (when) +import Data.List (nub) +import System.Exit (exitFailure) + +-- gloss +import Graphics.Gloss.Data.Picture + +-- test-gloss +import TestGloss + +-- koan +import Koan (rhine) + +main :: IO () +main = do + pics <- stepGlossRhine rhine $ (/ 30) <$> [0, 1 .. 30] + case nub pics of + [Blank, Translate 0 fiveish (ThickCircle 5.0 10.0), Translate 0 tenish (ThickCircle 5.0 10.0)] -> + when (fiveish < 5 || fiveish > 6 || tenish < 10 || tenish > 11) $ do + putStrLn "Those were the right pictures, but the speed at which they moved seems off." + exitFailure + unexpectedPics -> do + putStrLn $ "Unexpected pictures: " ++ show unexpectedPics + exitFailure diff --git a/koans/ui/1-gloss/4-user-input/Koan.hs b/koans/ui/1-gloss/4-user-input/Koan.hs new file mode 100644 index 0000000..c810a99 --- /dev/null +++ b/koans/ui/1-gloss/4-user-input/Koan.hs @@ -0,0 +1,130 @@ +{- | User input. + +The game moves forward every half a second. +The gloss pictures are drawn at a fixed frame rate of 30 frames per second. +User input, however, arrives at unpredictable times! +This means that user input events constitute a separate clock. + +rhine-gloss provides such a clock, 'GlossEventClockIO', but it ticks at every event that the gloss backend emits. +In our game of snake, we are only interested in those events where the player presses the right arrow key or the left arrow key. +Every time this happens, the event should be forwarded to the game logic, +where it should turn the direction into which the snake is heading. +-} +module Koan where + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- * Grid positions on the playing board + +-- | Currently, a snake will have size 1, so its body is defined by a single position. +data Position = Position + { x :: Int + , y :: Int + } + deriving (Eq, Ord) + +instance Semigroup Position where + Position x1 y1 <> Position x2 y2 = Position (x1 + x2) (y1 + y2) + +instance Monoid Position where + mempty = Position 0 0 + +renderPosition :: Position -> Picture +renderPosition Position {x, y} = translate (fromIntegral x) (fromIntegral y) $ circleSolid 0.6 + +-- | Directions in which the snake can head +data Direction = East | North | West | South + deriving (Enum) + +-- | A position changes by a direction in one step +stepPosition :: Direction -> Position -> Position +stepPosition East = (<> Position 1 0) +stepPosition North = (<> Position 0 1) +stepPosition West = (<> Position (-1) 0) +stepPosition South = (<> Position 0 (-1)) + +-- * User input + +-- | The user can change the direction of the snake +data Turn + = -- | Turn right (clockwise) when the right arrow is pressed. + TurnRight + | -- | Turn left (counterclockwise) when the left arrow is pressed. + TurnLeft + deriving (Show) + +-- | Select only those input events that correspond to turns of the snake +type UserClock = GlossClockUTC IO (SelectClock GlossEventClockIO Turn) + +-- | Ticks whenever the user wants to turn right or left +userClock :: UserClock +userClock = + glossClockUTC $ + SelectClock + { mainClock = GlossEventClockIO + , -- Select only those events here that are relevant for the game, the right arrow key and the left arrow key. + -- Have a look at https://hackage.haskell.org/package/gloss/docs/Graphics-Gloss-Interface-IO-Interact.html#t:Event. + -- All other events should be mapped to Nothing. + select = \case + _ -> _ + -- Hint: The important bits are KeyLeft and KeyRight! + } + +-- | User input to turn the snake +user :: ClSF GlossConc UserClock () Turn +user = tagS -- This simply returns the value of the current event, that is, the selected turn. + +-- * Game logic + +-- | Applying a turn to the current direction can give a new direction, shifted clockwise or counterclockwise. +changeDirection :: Turn -> Direction -> Direction +changeDirection TurnRight direction = toEnum $ (fromEnum direction - 1) `mod` 4 +changeDirection TurnLeft direction = toEnum $ (fromEnum direction + 1) `mod` 4 + +type GameClock = GlossConcTClock IO (Millisecond 500) + +gameClock :: GameClock +gameClock = glossConcTClock waitClock + +-- | The current position of the tiny snake, reacting to turns of the player. +game :: ClSF GlossConc GameClock (Maybe Turn) Position +-- unfold takes a starting state and a step function to create a signal function. +game = unfold (mempty, North) $ \turnMaybe (position, direction) -> + -- Use helper functions defined above to calculate the new position and direction! + let newDirection = _ + newPosition = _ + in Result _ _ + +-- * Visualization + +-- | Scale and paint a gloss picture +visualize :: BehaviourF GlossConc UTCTime Picture () +visualize = arrMCl $ scale 20 20 >>> paintAllIO + +-- | Draw at 30 FPS +type VisualizationClock = GlossClockUTC IO GlossSimClockIO + +visualizationClock :: VisualizationClock +visualizationClock = glossClockUTC GlossSimClockIO + +-- * The whole program + +rhine :: Rhine GlossConc (UserClock `SequentialClock` (GameClock `SequentialClock` VisualizationClock)) () () +rhine = + user + @@ userClock + -- The choice of resampling buffer here has a big influence on the game play. + -- A FIFO buffer will make sure that no user input is lost, but it also means that only one turn is performed per step. + >-- fifoBounded 1000 + --> (game >-> arr renderPosition @@ gameClock) + >-- keepLast mempty + --> visualize + @@ visualizationClock + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/4-user-input/solution/Koan.hs b/koans/ui/1-gloss/4-user-input/solution/Koan.hs new file mode 100644 index 0000000..1710235 --- /dev/null +++ b/koans/ui/1-gloss/4-user-input/solution/Koan.hs @@ -0,0 +1,132 @@ +{- | User input. + +The game moves forward every half a second. +The gloss pictures are drawn at a fixed frame rate of 30 frames per second. +User input, however, arrives at unpredictable times! +This means that user input events constitute a separate clock. + +rhine-gloss provides such a clock, 'GlossEventClockIO', but it ticks at every event that the gloss backend emits. +In our game of snake, we are only interested in those events where the player presses the right arrow key or the left arrow key. +Every time this happens, the event should be forwarded to the game logic, +where it should turn the direction into which the snake is heading. +-} +module Koan where + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- * Grid positions on the playing board + +-- | Currently, a snake will have size 1, so its body is defined by a single position. +data Position = Position + { x :: Int + , y :: Int + } + deriving (Eq, Ord) + +instance Semigroup Position where + Position x1 y1 <> Position x2 y2 = Position (x1 + x2) (y1 + y2) + +instance Monoid Position where + mempty = Position 0 0 + +renderPosition :: Position -> Picture +renderPosition Position {x, y} = translate (fromIntegral x) (fromIntegral y) $ circleSolid 0.6 + +-- | Directions in which the snake can head +data Direction = East | North | West | South + deriving (Enum) + +-- | A position changes by a direction in one step +stepPosition :: Direction -> Position -> Position +stepPosition East = (<> Position 1 0) +stepPosition North = (<> Position 0 1) +stepPosition West = (<> Position (-1) 0) +stepPosition South = (<> Position 0 (-1)) + +-- * User input + +-- | The user can change the direction of the snake +data Turn + = -- | Turn right (clockwise) when the right arrow is pressed. + TurnRight + | -- | Turn left (counterclockwise) when the left arrow is pressed. + TurnLeft + deriving (Show) + +-- | Select only those input events that correspond to turns of the snake +type UserClock = GlossClockUTC IO (SelectClock GlossEventClockIO Turn) + +-- | Ticks whenever the user wants to turn right or left +userClock :: UserClock +userClock = + glossClockUTC $ + SelectClock + { mainClock = GlossEventClockIO + , -- Select only those events here that are relevant for the game, the right arrow key and the left arrow key. + -- Have a look at https://hackage.haskell.org/package/gloss/docs/Graphics-Gloss-Interface-IO-Interact.html#t:Event. + -- All other events should be mapped to Nothing. + select = \case + (EventKey (SpecialKey KeyRight) Down _ _) -> Just TurnRight + (EventKey (SpecialKey KeyLeft) Down _ _) -> Just TurnLeft + _ -> Nothing + -- Hint: The important bits are KeyLeft and KeyRight! + } + +-- | User input to turn the snake +user :: ClSF GlossConc UserClock () Turn +user = tagS -- This simply returns the value of the current event, that is, the selected turn. + +-- * Game logic + +-- | Applying a turn to the current direction can give a new direction, shifted clockwise or counterclockwise. +changeDirection :: Turn -> Direction -> Direction +changeDirection TurnRight direction = toEnum $ (fromEnum direction - 1) `mod` 4 +changeDirection TurnLeft direction = toEnum $ (fromEnum direction + 1) `mod` 4 + +type GameClock = GlossConcTClock IO (Millisecond 500) + +gameClock :: GameClock +gameClock = glossConcTClock waitClock + +-- | The current position of the tiny snake, reacting to turns of the player. +game :: ClSF GlossConc GameClock (Maybe Turn) Position +-- unfold takes a starting state and a step function to create a signal function. +game = unfold (mempty, North) $ \turnMaybe (position, direction) -> + -- Use helper functions defined above to calculate the new position and direction! + let newDirection = maybe direction (`changeDirection` direction) turnMaybe + newPosition = stepPosition newDirection position + in Result (newPosition, newDirection) newPosition + +-- * Visualization + +-- | Scale and paint a gloss picture +visualize :: BehaviourF GlossConc UTCTime Picture () +visualize = arrMCl $ scale 20 20 >>> paintAllIO + +-- | Draw at 30 FPS +type VisualizationClock = GlossClockUTC IO GlossSimClockIO + +visualizationClock :: VisualizationClock +visualizationClock = glossClockUTC GlossSimClockIO + +-- * The whole program + +rhine :: Rhine GlossConc (UserClock `SequentialClock` (GameClock `SequentialClock` VisualizationClock)) () () +rhine = + user + @@ userClock + -- The choice of resampling buffer here has a big influence on the game play. + -- A FIFO buffer will make sure that no user input is lost, but it also means that only one turn is performed per step. + >-- fifoBounded 1000 + --> (game >-> arr renderPosition @@ gameClock) + >-- keepLast mempty + --> visualize + @@ visualizationClock + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/4-user-input/test/Test.hs b/koans/ui/1-gloss/4-user-input/test/Test.hs new file mode 100644 index 0000000..c75113e --- /dev/null +++ b/koans/ui/1-gloss/4-user-input/test/Test.hs @@ -0,0 +1,24 @@ +module Main where + +-- base +import Control.Monad (when) +import Data.List (nub) +import System.Exit (exitFailure) + +-- gloss +import Graphics.Gloss.Data.Picture + +-- test-gloss +import TestGloss + +-- koan +import Koan (rhine) + +main :: IO () +main = do + pics <- nub <$> stepGlossRhineWithInput rhine ((/ 30) <$> [0, 1 .. 30]) [keyRight, keyLeft] + let expected = [Scale 20.0 20.0 Blank, Scale 20.0 20.0 (Translate 1.0 0.0 (ThickCircle 0.3 0.6)), Scale 20.0 20.0 (Translate 1.0 1.0 (ThickCircle 0.3 0.6))] + when (pics /= expected) $ + do + putStrLn $ "Unexpected pictures: " ++ show pics + exitFailure diff --git a/koans/ui/1-gloss/5-randomness/Koan.hs b/koans/ui/1-gloss/5-randomness/Koan.hs new file mode 100644 index 0000000..fdd073c --- /dev/null +++ b/koans/ui/1-gloss/5-randomness/Koan.hs @@ -0,0 +1,248 @@ +{-# LANGUAGE Arrows #-} + +{- | Randomness. + +The classic game of snake has apples appearing at random places on the playing grid, +and the snake can move to eat them, growing larger every time it consumes one. +-} +module Koan where + +-- base +import Data.List.NonEmpty hiding (insert, unfold) +import GHC.Generics +import Prelude hiding (head) + +-- random +import System.Random +import System.Random.Stateful (UniformRange (..)) + +-- MonadRandom +import Control.Monad.Random + +-- containers +import Data.Set hiding (toList) + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- * Grid positions on the playing board + +boardSize :: Int +boardSize = 9 + +-- | A grid position on which a part of the snake body, or an apple, may be. +data Position = Position + { x :: Int + , y :: Int + } + deriving (Generic, Eq, Ord) + +instance Semigroup Position where + Position x1 y1 <> Position x2 y2 = Position (x1 + x2) (y1 + y2) + +instance Monoid Position where + mempty = Position 0 0 + +-- | To generate random apple positions +instance Uniform Position + +instance UniformRange Position where + uniformRM (Position xLow yLow, Position xHigh yHigh) g = Position <$> uniformRM (xLow, xHigh) g <*> uniformRM (yLow, yHigh) g + +instance Random Position + +renderPosition :: Position -> Picture +renderPosition Position {x, y} = translate (fromIntegral x) (fromIntegral y) $ circleSolid 0.6 + +-- * Directions in which the snake can head + +data Direction = East | North | West | South + deriving (Enum) + +-- | A position changes by a direction in one step +stepPosition :: Direction -> Position -> Position +stepPosition East = (<> Position 1 0) +stepPosition North = (<> Position 0 1) +stepPosition West = (<> Position (-1) 0) +stepPosition South = (<> Position 0 (-1)) + +-- * User input + +-- | The user can change the direction of the snake +data Turn + = -- | Turn right (clockwise) when the right arrow is pressed. + TurnRight + | -- | Turn left (counterclockwise) when the left arrow is pressed. + TurnLeft + deriving (Show) + +-- | Select only those input events that correspond to turns of the snake +type UserClock = GlossClockUTC IO (SelectClock GlossEventClockIO Turn) + +userClock :: UserClock +userClock = + glossClockUTC $ + SelectClock + { mainClock = GlossEventClockIO + , select = \case + (EventKey (SpecialKey KeyRight) Down _ _) -> Just TurnRight + (EventKey (SpecialKey KeyLeft) Down _ _) -> Just TurnLeft + _ -> Nothing + } + +-- | User input to turn the snake +user :: ClSF GlossConc UserClock () Turn +user = tagS + +-- * Game logic + +-- ** Snake + +-- | Applying a turn to the current direction can give a new direction, shifted clockwise or counterclockwise. +changeDirection :: Turn -> Direction -> Direction +changeDirection TurnRight direction = toEnum $ (fromEnum direction - 1) `mod` 4 +changeDirection TurnLeft direction = toEnum $ (fromEnum direction + 1) `mod` 4 + +-- | Whether the snake currently eats an apple. +data Eat = Eat | DontEat + +data Snake = Snake + { direction :: Direction + , body :: NonEmpty Position + } + +-- | A small snake. +snek :: Direction -> Position -> Snake +snek direction tinyBody = + Snake + { direction + , body = pure tinyBody + } + +-- | On every step, a snake can make a turn, and possibly eat an apple +stepSnake :: Maybe Turn -> Eat -> Snake -> Snake +stepSnake turnMaybe eat snake = + let + newDirection = maybe (direction snake) (`changeDirection` direction snake) turnMaybe + newHead = stepPosition newDirection $ Data.List.NonEmpty.head $ body snake + newTail = tailAfterMeal eat snake + in + Snake + { direction = newDirection + , body = newHead :| newTail + } + where + tailAfterMeal :: Eat -> Snake -> [Position] + tailAfterMeal DontEat = Data.List.NonEmpty.init . body + tailAfterMeal Eat = toList . body + +renderSnake :: Snake -> Picture +renderSnake = foldMap renderPosition . body + +-- ** Apples + +newtype Apple = Apple {getApple :: Position} + deriving (Eq, Ord) + +-- | Randomly generate a new apple every 10 steps, anywhere on the playing board. +newApple :: (Monad m) => ClSF (RandT StdGen m) GameClock () (Maybe Apple) +newApple = proc _ -> do + nSteps :: Int <- count -< () + if nSteps `mod` 10 == 1 + then -- Create a new random position for an apple, within a given range. + -- See https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Random.html + -- and keep in mind that RandT gives an instance of MonadRandom. + arr (Just <<< Apple) <<< _ -< (Position (-boardSize) (-boardSize), Position boardSize boardSize) + else returnA -< Nothing + +type Apples = Set Apple + +addAndEatApple :: + -- | Possibly a new apple appeared + Maybe Apple -> + -- | On this position the snake attempted to eat the apple + Position -> + -- | The previous collection of apples + Apples -> + (Apples, Eat) +addAndEatApple addedApple eatPosition oldApples = + let addedApples = maybe oldApples (`insert` oldApples) addedApple + newApples = delete (Apple eatPosition) addedApples + in (newApples, if size newApples < size addedApples then Eat else DontEat) + +renderApple :: Apple -> Picture +renderApple = color red . renderPosition . getApple + +-- ** Combining snake and apples + +type GameClock = GlossConcTClock IO (Millisecond 500) + +gameClock :: GameClock +gameClock = glossConcTClock waitClock + +{- | Given the current user input and whether an apple was eaten in the last round, + output the current snake. +-} +snakeSF :: ClSF GlossConc GameClock (Maybe Turn, Eat) Snake +snakeSF = unfold_ (snek North mempty) $ \(turn, eat) s -> stepSnake turn eat s + +{- | Given the current position of the snake head, + output the set of apples, and whether an apple is currently being eaten +-} +applesSF :: ClSF GlossConc GameClock Position (Apples, Eat) +applesSF = feedback empty $ proc (eatPosition, oldApples) -> do + -- We want to reuse newApple from above to occasionally add new apples. + -- But it's not in the GlossConc monad! + -- Have a look again at https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Random.html + -- to find a way to run newApple in GlossConc by interpreting the RandT monad transformer. + addedApple <- _ -< () + let (newApples, eat) = addAndEatApple addedApple eatPosition oldApples + returnA -< ((newApples, eat), newApples) + +-- | Given the current user input, output the current snake and the apples +game :: ClSF GlossConc GameClock (Maybe Turn) (Snake, Apples) +-- Tie the big knot! Combine snakeSF and applesSF, but take care: +-- Each needs input from the other. +-- snakeSF needs to know whether an apple was eaten in the last round, +-- and applesSF needs to know where the head of the snake is now. +game = _ + +-- Hint 1: Have a look back at the koan basic-2-4-count-all-the-words! +-- Hint 2: Save the information of whether an apple is currently being eaten as internal state, +-- so you will know in the next step whether one was eaten in the last round. +-- Start like this: +-- feedback DontEat _ + +render :: (Snake, Apples) -> Picture +render (snake, apples) = renderSnake snake <> foldMap renderApple apples + +-- * Visualization + +-- | Scale and paint a gloss picture +visualize :: BehaviourF GlossConc UTCTime Picture () +visualize = arrMCl $ scale 20 20 >>> paintAllIO + +-- | Draw at 30 FPS +type VisualizationClock = GlossClockUTC IO GlossSimClockIO + +visualizationClock :: VisualizationClock +visualizationClock = glossClockUTC GlossSimClockIO + +-- * The whole program + +rhine :: Rhine GlossConc (UserClock `SequentialClock` (GameClock `SequentialClock` VisualizationClock)) () () +rhine = + user + @@ userClock + >-- fifoBounded 1000 + --> (game >-> arr render @@ gameClock) + >-- keepLast mempty + --> visualize + @@ visualizationClock + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/5-randomness/solution/Koan.hs b/koans/ui/1-gloss/5-randomness/solution/Koan.hs new file mode 100644 index 0000000..f5ff06c --- /dev/null +++ b/koans/ui/1-gloss/5-randomness/solution/Koan.hs @@ -0,0 +1,251 @@ +{-# LANGUAGE Arrows #-} + +{- | Randomness. + +The classic game of snake has apples appearing at random places on the playing grid, +and the snake can move to eat them, growing larger every time it consumes one. +-} +module Koan where + +-- base +import Data.List.NonEmpty hiding (insert, unfold) +import GHC.Generics +import Prelude hiding (head) + +-- random +import System.Random +import System.Random.Stateful (UniformRange (..)) + +-- MonadRandom +import Control.Monad.Random + +-- containers +import Data.Set hiding (toList) + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- * Grid positions on the playing board + +boardSize :: Int +boardSize = 9 + +-- | A grid position on which a part of the snake body, or an apple, may be. +data Position = Position + { x :: Int + , y :: Int + } + deriving (Generic, Eq, Ord) + +instance Semigroup Position where + Position x1 y1 <> Position x2 y2 = Position (x1 + x2) (y1 + y2) + +instance Monoid Position where + mempty = Position 0 0 + +-- | To generate random apple positions +instance Uniform Position + +instance UniformRange Position where + uniformRM (Position xLow yLow, Position xHigh yHigh) g = Position <$> uniformRM (xLow, xHigh) g <*> uniformRM (yLow, yHigh) g + +instance Random Position + +renderPosition :: Position -> Picture +renderPosition Position {x, y} = translate (fromIntegral x) (fromIntegral y) $ circleSolid 0.6 + +-- * Directions in which the snake can head + +data Direction = East | North | West | South + deriving (Enum) + +-- | A position changes by a direction in one step +stepPosition :: Direction -> Position -> Position +stepPosition East = (<> Position 1 0) +stepPosition North = (<> Position 0 1) +stepPosition West = (<> Position (-1) 0) +stepPosition South = (<> Position 0 (-1)) + +-- * User input + +-- | The user can change the direction of the snake +data Turn + = -- | Turn right (clockwise) when the right arrow is pressed. + TurnRight + | -- | Turn left (counterclockwise) when the left arrow is pressed. + TurnLeft + deriving (Show) + +-- | Select only those input events that correspond to turns of the snake +type UserClock = GlossClockUTC IO (SelectClock GlossEventClockIO Turn) + +userClock :: UserClock +userClock = + glossClockUTC $ + SelectClock + { mainClock = GlossEventClockIO + , select = \case + (EventKey (SpecialKey KeyRight) Down _ _) -> Just TurnRight + (EventKey (SpecialKey KeyLeft) Down _ _) -> Just TurnLeft + _ -> Nothing + } + +-- | User input to turn the snake +user :: ClSF GlossConc UserClock () Turn +user = tagS + +-- * Game logic + +-- ** Snake + +-- | Applying a turn to the current direction can give a new direction, shifted clockwise or counterclockwise. +changeDirection :: Turn -> Direction -> Direction +changeDirection TurnRight direction = toEnum $ (fromEnum direction - 1) `mod` 4 +changeDirection TurnLeft direction = toEnum $ (fromEnum direction + 1) `mod` 4 + +-- | Whether the snake currently eats an apple. +data Eat = Eat | DontEat + +data Snake = Snake + { direction :: Direction + , body :: NonEmpty Position + } + +-- | A small snake. +snek :: Direction -> Position -> Snake +snek direction tinyBody = + Snake + { direction + , body = pure tinyBody + } + +-- | On every step, a snake can make a turn, and possibly eat an apple +stepSnake :: Maybe Turn -> Eat -> Snake -> Snake +stepSnake turnMaybe eat snake = + let + newDirection = maybe (direction snake) (`changeDirection` direction snake) turnMaybe + newHead = stepPosition newDirection $ Data.List.NonEmpty.head $ body snake + newTail = tailAfterMeal eat snake + in + Snake + { direction = newDirection + , body = newHead :| newTail + } + where + tailAfterMeal :: Eat -> Snake -> [Position] + tailAfterMeal DontEat = Data.List.NonEmpty.init . body + tailAfterMeal Eat = toList . body + +renderSnake :: Snake -> Picture +renderSnake = foldMap renderPosition . body + +-- ** Apples + +newtype Apple = Apple {getApple :: Position} + deriving (Eq, Ord) + +-- | Randomly generate a new apple every 10 steps, anywhere on the playing board. +newApple :: (Monad m) => ClSF (RandT StdGen m) GameClock () (Maybe Apple) +newApple = proc _ -> do + nSteps :: Int <- count -< () + if nSteps `mod` 10 == 1 + then -- Create a new random position for an apple, within a given range. + -- See https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Random.html + -- and keep in mind that RandT gives an instance of MonadRandom. + arr (Just <<< Apple) <<< getRandomRS -< (Position (-boardSize) (-boardSize), Position boardSize boardSize) + else returnA -< Nothing + +type Apples = Set Apple + +addAndEatApple :: + -- | Possibly a new apple appeared + Maybe Apple -> + -- | On this position the snake attempted to eat the apple + Position -> + -- | The previous collection of apples + Apples -> + (Apples, Eat) +addAndEatApple addedApple eatPosition oldApples = + let addedApples = maybe oldApples (`insert` oldApples) addedApple + newApples = delete (Apple eatPosition) addedApples + in (newApples, if size newApples < size addedApples then Eat else DontEat) + +renderApple :: Apple -> Picture +renderApple = color red . renderPosition . getApple + +-- ** Combining snake and apples + +type GameClock = GlossConcTClock IO (Millisecond 500) + +gameClock :: GameClock +gameClock = glossConcTClock waitClock + +{- | Given the current user input and whether an apple was eaten in the last round, + output the current snake. +-} +snakeSF :: ClSF GlossConc GameClock (Maybe Turn, Eat) Snake +snakeSF = unfold_ (snek North mempty) $ \(turn, eat) s -> stepSnake turn eat s + +{- | Given the current position of the snake head, + output the set of apples, and whether an apple is currently being eaten +-} +applesSF :: ClSF GlossConc GameClock Position (Apples, Eat) +applesSF = feedback empty $ proc (eatPosition, oldApples) -> do + -- We want to reuse newApple from above to occasionally add new apples. + -- But it's not in the GlossConc monad! + -- Have a look again at https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Random.html + -- to find a way to run newApple in GlossConc by interpreting the RandT monad transformer. + addedApple <- evalRandIOS' newApple -< () + let (newApples, eat) = addAndEatApple addedApple eatPosition oldApples + returnA -< ((newApples, eat), newApples) + +-- | Given the current user input, output the current snake and the apples +game :: ClSF GlossConc GameClock (Maybe Turn) (Snake, Apples) +-- Tie the big knot! Combine snakeSF and applesSF, but take care: +-- Each needs input from the other. +-- snakeSF needs to know whether an apple was eaten in the last round, +-- and applesSF needs to know where the head of the snake is now. +game = feedback DontEat $ proc (turn, eat) -> do + snake <- snakeSF -< (turn, eat) + (apples, eatNext) <- applesSF -< head $ body snake + returnA -< ((snake, apples), eatNext) + +-- Hint 1: Have a look back at the koan basic-2-4-count-all-the-words! +-- Hint 2: Save the information of whether an apple is currently being eaten as internal state, +-- so you will know in the next step whether one was eaten in the last round. +-- Start like this: +-- feedback DontEat _ + +render :: (Snake, Apples) -> Picture +render (snake, apples) = renderSnake snake <> foldMap renderApple apples + +-- * Visualization + +-- | Scale and paint a gloss picture +visualize :: BehaviourF GlossConc UTCTime Picture () +visualize = arrMCl $ scale 20 20 >>> paintAllIO + +-- | Draw at 30 FPS +type VisualizationClock = GlossClockUTC IO GlossSimClockIO + +visualizationClock :: VisualizationClock +visualizationClock = glossClockUTC GlossSimClockIO + +-- * The whole program + +rhine :: Rhine GlossConc (UserClock `SequentialClock` (GameClock `SequentialClock` VisualizationClock)) () () +rhine = + user + @@ userClock + >-- fifoBounded 1000 + --> (game >-> arr render @@ gameClock) + >-- keepLast mempty + --> visualize + @@ visualizationClock + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/5-randomness/test/Test.hs b/koans/ui/1-gloss/5-randomness/test/Test.hs new file mode 100644 index 0000000..6c6974a --- /dev/null +++ b/koans/ui/1-gloss/5-randomness/test/Test.hs @@ -0,0 +1,47 @@ +module Main where + +-- base +import Control.Monad (when) +import Data.List (nub) +import Data.List.NonEmpty (NonEmpty ((:|))) +import System.Exit (exitFailure) + +-- gloss +import Graphics.Gloss.Data.Picture + +-- test-gloss +import TestGloss + +-- koan + +import Control.Monad.Random (mkStdGen, setStdGen) +import Data.Set (singleton) +import Koan (Apple (Apple), Direction (North), Position (..), Snake (..), render, rhine, snek) + +main :: IO () +main = do + setStdGen $ mkStdGen 0 + pics <- fmap nub $ stepGlossRhineWithInput rhine ((/ 30) <$> [0, 1 .. 150]) $ cycle [keyRight, keyLeft] + let apple = Apple $ Position {x = 4, y = 4} -- By fixing the stdgen the apple will always be here + beforeEating = + (,singleton apple) . snek North + <$> [ Position {x = 1, y = 0} + , Position {x = 1, y = 1} + , Position {x = 2, y = 1} + , Position {x = 2, y = 2} + , Position {x = 3, y = 2} + , Position {x = 3, y = 3} + , Position {x = 4, y = 3} + ] + afterEating = + (,mempty) + <$> [ Snake {direction = North, body = Position {x = 4, y = 4} :| []} + , Snake {direction = North, body = Position {x = 5, y = 4} :| [Position {x = 4, y = 4}]} + , Snake {direction = North, body = Position {x = 5, y = 5} :| [Position {x = 5, y = 4}]} + ] + expected = map (scale 20 20) $ blank : map render (beforeEating ++ afterEating) + when (pics /= expected) $ + do + putStrLn $ "Unexpected pictures:\n" ++ unlines (show <$> pics) + putStrLn $ "Expected:\n" ++ unlines (show <$> expected) + exitFailure diff --git a/koans/ui/1-gloss/6-control-flow/Koan.hs b/koans/ui/1-gloss/6-control-flow/Koan.hs new file mode 100644 index 0000000..0e9f841 --- /dev/null +++ b/koans/ui/1-gloss/6-control-flow/Koan.hs @@ -0,0 +1,252 @@ +{-# LANGUAGE Arrows #-} + +{- | Control flow. + +Sometimes you might want to change the game. +In Rhine (and other frameworks based on monadic stream functions like dunai, bearriver, and essence-of-live-coding), +we throw and catch exceptions to switch between different signal functions. +To do this, there is a monad interface for exception-throwing signal functions, 'ClSFExcept'. + +In short: + +* Exceptions are effects in the 'ExceptT' monad transformer, which is added to the monad stack of the 'ClSF'. +* To enter the new 'ClSFExcept' monad context, you can use: + * 'try :: ClSF (ExceptT e m) cl a b -> ClSFExcept cl a b m e' for switching to a 'ClSF' that may throw an exception + * 'safe :: ClSF m cl a b -> ClSFExcept cl a b m void' for finally switching to a 'ClSF' that will never throw an exception +* To handle exceptions, use do notation: + A 'ClSFExcept' value returns its exception, and you can then switch to the next signal function by adding a statement to the do block. +* To leave the 'ClSFExcept' context after all exceptions have been handled, use 'safely :: ClSFExcept cl a b m Void -> ClSF m cl a b' + +See https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Except.html +and section 4.2 of the original research article https://www.manuelbaerenz.de/files/Rhine.pdf for more details. + +With these ingredients, we can change the game to be over! +Fix the code such that if the snake hits the boundaries or itself, the game will change to a special gameover state. +-} +module Koan where + +-- base +import Data.List.NonEmpty hiding (insert, unfold) +import GHC.Generics +import Prelude hiding (head) + +-- random +import System.Random +import System.Random.Stateful (UniformRange (..)) + +-- MonadRandom +import Control.Monad.Random + +-- containers +import Data.Set hiding (toList) + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- * Grid positions on the playing board + +boardSize :: Int +boardSize = 9 + +-- | A grid position on which a part of the snake body, or an apple, may be. +data Position = Position + { x :: Int + , y :: Int + } + deriving (Generic, Eq, Ord) + +instance Semigroup Position where + Position x1 y1 <> Position x2 y2 = Position (x1 + x2) (y1 + y2) + +instance Monoid Position where + mempty = Position 0 0 + +-- | To generate random apple positions +instance Uniform Position + +instance UniformRange Position where + uniformRM (Position xLow yLow, Position xHigh yHigh) g = Position <$> uniformRM (xLow, xHigh) g <*> uniformRM (yLow, yHigh) g + +instance Random Position + +renderPosition :: Position -> Picture +renderPosition Position {x, y} = translate (fromIntegral x) (fromIntegral y) $ circleSolid 0.6 + +-- * Directions in which the snake can head + +data Direction = East | North | West | South + deriving (Enum) + +-- | A position changes by a direction in one step +stepPosition :: Direction -> Position -> Position +stepPosition East = (<> Position 1 0) +stepPosition North = (<> Position 0 1) +stepPosition West = (<> Position (-1) 0) +stepPosition South = (<> Position 0 (-1)) + +-- | The user can change the direction of the snake +data Turn + = -- | Turn right (clockwise) when the right arrow is pressed. + TurnRight + | -- | Turn left (counterclockwise) when the left arrow is pressed. + TurnLeft + deriving (Show) + +changeDirection :: Turn -> Direction -> Direction +changeDirection TurnRight direction = toEnum $ (fromEnum direction - 1) `mod` 4 +changeDirection TurnLeft direction = toEnum $ (fromEnum direction + 1) `mod` 4 + +-- | Whether the snake currently eats an apple. +data Eat = Eat | DontEat + +data Snake = Snake + { direction :: Direction + , body :: NonEmpty Position + } + +-- | A small snake. +snek :: Direction -> Position -> Snake +snek direction tinyBody = + Snake + { direction + , body = pure tinyBody + } + +-- | On every step, a snake can make a turn, and possibly eat an apple +stepSnake :: Maybe Turn -> Eat -> Snake -> Snake +stepSnake turnMaybe eat snake = + let + newDirection = maybe (direction snake) (`changeDirection` direction snake) turnMaybe + newHead = stepPosition newDirection $ Data.List.NonEmpty.head $ body snake + newTail = tailAfterMeal eat snake + in + Snake + { direction = newDirection + , body = newHead :| newTail + } + where + tailAfterMeal :: Eat -> Snake -> [Position] + tailAfterMeal DontEat = Data.List.NonEmpty.init . body + tailAfterMeal Eat = toList . body + +renderSnake :: Snake -> Picture +renderSnake = foldMap renderPosition . body + +newtype Apple = Apple {getApple :: Position} + deriving (Eq, Ord) + +newApple :: (MonadRandom m) => ClSF m GameClock () (Maybe Apple) +newApple = proc _ -> do + nSteps :: Int <- count -< () + if nSteps `mod` 10 == 1 + then arr (Just <<< Apple) <<< getRandomRS -< (Position (-boardSize) (-boardSize), Position boardSize boardSize) + else returnA -< Nothing + +type Apples = Set Apple + +addAndEatApple :: + -- | Possibly a new apple appeared + Maybe Apple -> + -- | On this position the snake attempted to eat the apple + Position -> + -- | The previous collection of apples + Apples -> + (Apples, Eat) +addAndEatApple addedApple eatPosition oldApples = + let addedApples = maybe oldApples (`insert` oldApples) addedApple + newApples = delete (Apple eatPosition) addedApples + in (newApples, if size newApples < size addedApples then Eat else DontEat) + +renderApple :: Apple -> Picture +renderApple = color red . renderPosition . getApple + +type GameClock = GlossConcTClock IO (Millisecond 500) + +gameClock :: GameClock +gameClock = glossConcTClock waitClock + +snakeSF :: ClSF GlossConc GameClock (Maybe Turn, Eat) Snake +snakeSF = unfold_ (snek North mempty) $ \(turn, eat) s -> stepSnake turn eat s + +applesSF :: ClSF GlossConc GameClock Position (Apples, Eat) +applesSF = feedback empty $ proc (eatPosition, oldApples) -> do + addedApple <- evalRandIOS' newApple -< () + let (newApples, eat) = addAndEatApple addedApple eatPosition oldApples + returnA -< ((newApples, eat), newApples) + +snakeAndApples :: ClSF GlossConc GameClock (Maybe Turn) (Snake, Apples) +snakeAndApples = feedback DontEat $ proc (turn, eat) -> do + snake <- snakeSF -< (turn, eat) + (apples, eatNext) <- applesSF -< head $ body snake + returnA -< ((snake, apples), eatNext) + +-- | Whether a snake hits the boundaries or bites itself +illegal :: Snake -> Bool +illegal Snake {body = head_@Position {x, y} :| tail_} = + head_ `elem` tail_ + || x < (-boardSize) + || x > boardSize + || y < (-boardSize) + || y > boardSize + +-- | Play snake until the snake is in an illegal state +game :: ClSF GlossConc GameClock (Maybe Turn) (Maybe (Snake, Apples)) +game = safely $ do + _ + +-- Have a look at https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Except.html. +-- We first want to play 'snakeAndApples' until the 'illegal' function returns 'True' on the current snake. +-- This has to throw an exception. +-- Catch the exception by outputting 'Nothing' forever. + +-- Hint 1: Combine the following building blocks (plus a few things from base) to solve the puzzle: +-- try, liftClSF, snakeAndApples, throwOnCond, illegal, safe +-- Hint 2: The functions you need to combine are already in the right order +-- Hint 3: The do notation has to have 2 statements in total +-- Hint 4: These are the base functions and constructors you might also need: +-- $ (several times), >>> (several times), fst, arr, Just, pure, Nothing + +render :: Maybe (Snake, Apples) -> Picture +render (Just (snake, apples)) = renderSnake snake <> foldMap renderApple apples +render Nothing = gameover + +gameover :: Picture +gameover = translate (-3.5) 0 $ scale 0.01 0.01 $ text "Game over!" + +-- | Scale and paint a gloss picture +visualize :: BehaviourF GlossConc UTCTime Picture () +visualize = arrMCl $ scale 20 20 >>> paintAllIO + +-- | Draw at 30 FPS +type VisualizationClock = GlossClockUTC IO GlossSimClockIO + +visualizationClock :: VisualizationClock +visualizationClock = glossClockUTC GlossSimClockIO + +-- | Select only those input events that correspond to turns of the snake +type UserClock = GlossClockUTC IO (SelectClock GlossEventClockIO Turn) + +userClock :: UserClock +userClock = + glossClockUTC $ + SelectClock + { mainClock = GlossEventClockIO + , select = \case + (EventKey (SpecialKey KeyRight) Down _ _) -> Just TurnRight + (EventKey (SpecialKey KeyLeft) Down _ _) -> Just TurnLeft + _ -> Nothing + } + +-- | User input to turn the snake +user :: ClSF GlossConc UserClock () Turn +user = tagS + +rhine :: Rhine GlossConc (UserClock `SequentialClock` (GameClock `SequentialClock` VisualizationClock)) () () +rhine = user @@ userClock >-- fifoBounded 1000 --> (game >-> arr render @@ gameClock) >-- keepLast mempty --> visualize @@ visualizationClock + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/6-control-flow/solution/Koan.hs b/koans/ui/1-gloss/6-control-flow/solution/Koan.hs new file mode 100644 index 0000000..f110f25 --- /dev/null +++ b/koans/ui/1-gloss/6-control-flow/solution/Koan.hs @@ -0,0 +1,253 @@ +{-# LANGUAGE Arrows #-} + +{- | Control flow. + +Sometimes you might want to change the game. +In Rhine (and other frameworks based on monadic stream functions like dunai, bearriver, and essence-of-live-coding), +we throw and catch exceptions to switch between different signal functions. +To do this, there is a monad interface for exception-throwing signal functions, 'ClSFExcept'. + +In short: + +* Exceptions are effects in the 'ExceptT' monad transformer, which is added to the monad stack of the 'ClSF'. +* To enter the new 'ClSFExcept' monad context, you can use: + * 'try :: ClSF (ExceptT e m) cl a b -> ClSFExcept cl a b m e' for switching to a 'ClSF' that may throw an exception + * 'safe :: ClSF m cl a b -> ClSFExcept cl a b m void' for finally switching to a 'ClSF' that will never throw an exception +* To handle exceptions, use do notation: + A 'ClSFExcept' value returns its exception, and you can then switch to the next signal function by adding a statement to the do block. +* To leave the 'ClSFExcept' context after all exceptions have been handled, use 'safely :: ClSFExcept cl a b m Void -> ClSF m cl a b' + +See https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Except.html +and section 4.2 of the original research article https://www.manuelbaerenz.de/files/Rhine.pdf for more details. + +With these ingredients, we can change the game to be over! +Fix the code such that if the snake hits the boundaries or itself, the game will change to a special gameover state. +-} +module Koan where + +-- base +import Data.List.NonEmpty hiding (insert, unfold) +import GHC.Generics +import Prelude hiding (head) + +-- random +import System.Random +import System.Random.Stateful (UniformRange (..)) + +-- MonadRandom +import Control.Monad.Random + +-- containers +import Data.Set hiding (toList) + +-- rhine +import FRP.Rhine + +-- rhine-gloss +import FRP.Rhine.Gloss + +-- * Grid positions on the playing board + +boardSize :: Int +boardSize = 9 + +-- | A grid position on which a part of the snake body, or an apple, may be. +data Position = Position + { x :: Int + , y :: Int + } + deriving (Generic, Eq, Ord) + +instance Semigroup Position where + Position x1 y1 <> Position x2 y2 = Position (x1 + x2) (y1 + y2) + +instance Monoid Position where + mempty = Position 0 0 + +-- | To generate random apple positions +instance Uniform Position + +instance UniformRange Position where + uniformRM (Position xLow yLow, Position xHigh yHigh) g = Position <$> uniformRM (xLow, xHigh) g <*> uniformRM (yLow, yHigh) g + +instance Random Position + +renderPosition :: Position -> Picture +renderPosition Position {x, y} = translate (fromIntegral x) (fromIntegral y) $ circleSolid 0.6 + +-- * Directions in which the snake can head + +data Direction = East | North | West | South + deriving (Enum) + +-- | A position changes by a direction in one step +stepPosition :: Direction -> Position -> Position +stepPosition East = (<> Position 1 0) +stepPosition North = (<> Position 0 1) +stepPosition West = (<> Position (-1) 0) +stepPosition South = (<> Position 0 (-1)) + +-- | The user can change the direction of the snake +data Turn + = -- | Turn right (clockwise) when the right arrow is pressed. + TurnRight + | -- | Turn left (counterclockwise) when the left arrow is pressed. + TurnLeft + deriving (Show) + +changeDirection :: Turn -> Direction -> Direction +changeDirection TurnRight direction = toEnum $ (fromEnum direction - 1) `mod` 4 +changeDirection TurnLeft direction = toEnum $ (fromEnum direction + 1) `mod` 4 + +-- | Whether the snake currently eats an apple. +data Eat = Eat | DontEat + +data Snake = Snake + { direction :: Direction + , body :: NonEmpty Position + } + +-- | A small snake. +snek :: Direction -> Position -> Snake +snek direction tinyBody = + Snake + { direction + , body = pure tinyBody + } + +-- | On every step, a snake can make a turn, and possibly eat an apple +stepSnake :: Maybe Turn -> Eat -> Snake -> Snake +stepSnake turnMaybe eat snake = + let + newDirection = maybe (direction snake) (`changeDirection` direction snake) turnMaybe + newHead = stepPosition newDirection $ Data.List.NonEmpty.head $ body snake + newTail = tailAfterMeal eat snake + in + Snake + { direction = newDirection + , body = newHead :| newTail + } + where + tailAfterMeal :: Eat -> Snake -> [Position] + tailAfterMeal DontEat = Data.List.NonEmpty.init . body + tailAfterMeal Eat = toList . body + +renderSnake :: Snake -> Picture +renderSnake = foldMap renderPosition . body + +newtype Apple = Apple {getApple :: Position} + deriving (Eq, Ord) + +newApple :: (MonadRandom m) => ClSF m GameClock () (Maybe Apple) +newApple = proc _ -> do + nSteps :: Int <- count -< () + if nSteps `mod` 10 == 1 + then arr (Just <<< Apple) <<< getRandomRS -< (Position (-boardSize) (-boardSize), Position boardSize boardSize) + else returnA -< Nothing + +type Apples = Set Apple + +addAndEatApple :: + -- | Possibly a new apple appeared + Maybe Apple -> + -- | On this position the snake attempted to eat the apple + Position -> + -- | The previous collection of apples + Apples -> + (Apples, Eat) +addAndEatApple addedApple eatPosition oldApples = + let addedApples = maybe oldApples (`insert` oldApples) addedApple + newApples = delete (Apple eatPosition) addedApples + in (newApples, if size newApples < size addedApples then Eat else DontEat) + +renderApple :: Apple -> Picture +renderApple = color red . renderPosition . getApple + +type GameClock = GlossConcTClock IO (Millisecond 500) + +gameClock :: GameClock +gameClock = glossConcTClock waitClock + +snakeSF :: ClSF GlossConc GameClock (Maybe Turn, Eat) Snake +snakeSF = unfold_ (snek North mempty) $ \(turn, eat) s -> stepSnake turn eat s + +applesSF :: ClSF GlossConc GameClock Position (Apples, Eat) +applesSF = feedback empty $ proc (eatPosition, oldApples) -> do + addedApple <- evalRandIOS' newApple -< () + let (newApples, eat) = addAndEatApple addedApple eatPosition oldApples + returnA -< ((newApples, eat), newApples) + +snakeAndApples :: ClSF GlossConc GameClock (Maybe Turn) (Snake, Apples) +snakeAndApples = feedback DontEat $ proc (turn, eat) -> do + snake <- snakeSF -< (turn, eat) + (apples, eatNext) <- applesSF -< head $ body snake + returnA -< ((snake, apples), eatNext) + +-- | Whether a snake hits the boundaries or bites itself +illegal :: Snake -> Bool +illegal Snake {body = head_@Position {x, y} :| tail_} = + head_ `elem` tail_ + || x < (-boardSize) + || x > boardSize + || y < (-boardSize) + || y > boardSize + +-- | Play snake until the snake is in an illegal state +game :: ClSF GlossConc GameClock (Maybe Turn) (Maybe (Snake, Apples)) +game = safely $ do + try $ liftClSF snakeAndApples >>> throwOnCond (fst >>> illegal) () >>> arr Just + safe $ pure Nothing + +-- Have a look at https://hackage.haskell.org/package/rhine/docs/FRP-Rhine-ClSF-Except.html. +-- We first want to play 'snakeAndApples' until the 'illegal' function returns 'True' on the current snake. +-- This has to throw an exception. +-- Catch the exception by outputting 'Nothing' forever. + +-- Hint 1: Combine the following building blocks (plus a few things from base) to solve the puzzle: +-- try, liftClSF, snakeAndApples, throwOnCond, illegal, safe +-- Hint 2: The functions you need to combine are already in the right order +-- Hint 3: The do notation has to have 2 statements in total +-- Hint 4: These are the base functions and constructors you might also need: +-- $ (several times), >>> (several times), fst, arr, Just, pure, Nothing + +render :: Maybe (Snake, Apples) -> Picture +render (Just (snake, apples)) = renderSnake snake <> foldMap renderApple apples +render Nothing = gameover + +gameover :: Picture +gameover = translate (-3.5) 0 $ scale 0.01 0.01 $ text "Game over!" + +-- | Scale and paint a gloss picture +visualize :: BehaviourF GlossConc UTCTime Picture () +visualize = arrMCl $ scale 20 20 >>> paintAllIO + +-- | Draw at 30 FPS +type VisualizationClock = GlossClockUTC IO GlossSimClockIO + +visualizationClock :: VisualizationClock +visualizationClock = glossClockUTC GlossSimClockIO + +-- | Select only those input events that correspond to turns of the snake +type UserClock = GlossClockUTC IO (SelectClock GlossEventClockIO Turn) + +userClock :: UserClock +userClock = + glossClockUTC $ + SelectClock + { mainClock = GlossEventClockIO + , select = \case + (EventKey (SpecialKey KeyRight) Down _ _) -> Just TurnRight + (EventKey (SpecialKey KeyLeft) Down _ _) -> Just TurnLeft + _ -> Nothing + } + +-- | User input to turn the snake +user :: ClSF GlossConc UserClock () Turn +user = tagS + +rhine :: Rhine GlossConc (UserClock `SequentialClock` (GameClock `SequentialClock` VisualizationClock)) () () +rhine = user @@ userClock >-- fifoBounded 1000 --> (game >-> arr render @@ gameClock) >-- keepLast mempty --> visualize @@ visualizationClock + +main :: IO () +-- Make sure to keep this definition here as it is: The tests depend on it. +main = flowGlossIO defaultSettings rhine diff --git a/koans/ui/1-gloss/6-control-flow/test/Test.hs b/koans/ui/1-gloss/6-control-flow/test/Test.hs new file mode 100644 index 0000000..0982b14 --- /dev/null +++ b/koans/ui/1-gloss/6-control-flow/test/Test.hs @@ -0,0 +1,60 @@ +module Main where + +-- base +import Control.Monad (when) +import Data.List (nub) +import Data.List.NonEmpty (NonEmpty ((:|))) +import Data.Set (singleton) +import System.Exit (exitFailure) + +-- MonadRandom +import Control.Monad.Random (mkStdGen, setStdGen) + +-- gloss +import Graphics.Gloss.Data.Picture + +-- test-gloss +import TestGloss + +-- koan +import Koan (Apple (Apple), Direction (North), Position (..), Snake (..), render, rhine, snek) + +main :: IO () +main = do + putStrLn "Don't worry, this will take half a minute :) ..." + setStdGen $ mkStdGen 0 + pics <- fmap nub $ stepGlossRhineWithInput rhine ((/ 30) <$> [0, 1 .. 360]) $ cycle [keyRight, keyLeft] + let apple = Apple Position {x = 4, y = 4} -- By fixing the stdgen the apple will always be here + beforeEating = + (,singleton apple) . snek North + <$> [ Position {x = 1, y = 0} + , Position {x = 1, y = 1} + , Position {x = 2, y = 1} + , Position {x = 2, y = 2} + , Position {x = 3, y = 2} + , Position {x = 3, y = 3} + , Position {x = 4, y = 3} + ] + afterEating = + (,mempty) + <$> [ Snake {direction = North, body = Position {x = 4, y = 4} :| []} + , Snake {direction = North, body = Position {x = 5, y = 4} :| [Position {x = 4, y = 4}]} + , Snake {direction = North, body = Position {x = 5, y = 5} :| [Position {x = 5, y = 4}]} + ] + secondApple = + (,singleton $ Apple Position {x = -9, y = -7}) + <$> [ Snake {direction = North, body = Position {x = 6, y = 5} :| [Position {x = 5, y = 5}]} + , Snake {direction = North, body = Position {x = 6, y = 6} :| [Position {x = 6, y = 5}]} + , Snake {direction = North, body = Position {x = 7, y = 6} :| [Position {x = 6, y = 6}]} + , Snake {direction = North, body = Position {x = 7, y = 7} :| [Position {x = 7, y = 6}]} + , Snake {direction = North, body = Position {x = 8, y = 7} :| [Position {x = 7, y = 7}]} + , Snake {direction = North, body = Position {x = 8, y = 8} :| [Position {x = 8, y = 7}]} + , Snake {direction = North, body = Position {x = 9, y = 8} :| [Position {x = 8, y = 8}]} + , Snake {direction = North, body = Position {x = 9, y = 9} :| [Position {x = 9, y = 8}]} + ] + expected = map (scale 20 20) $ blank : map render ((Just <$> beforeEating ++ afterEating ++ secondApple) ++ [Nothing]) + when (pics /= expected) $ + do + putStrLn $ "Unexpected pictures:\n" ++ unlines (show <$> pics) + putStrLn $ "Expected:\n" ++ unlines (show <$> expected) + exitFailure diff --git a/presentation/presentation.md b/presentation/presentation.md index 251e042..8f96d21 100644 --- a/presentation/presentation.md +++ b/presentation/presentation.md @@ -242,4 +242,5 @@ Manuel (he/him), turion on Discord/Discourse/Github/..., turion@types.pl on Mast * Websocket clock: `https://hackage.haskell.org/package/wuss` * Webserver: `https://hackage.haskell.org/package/wai` * Machine learning: `https://hackage.haskell.org/package/rhine-bayes` +* Port the snake to `rhine-terminal` * Challenge: Rhine entry in https://github.com/gelisam/frp-zoo diff --git a/presentation/presentation.pdf b/presentation/presentation.pdf index 74a42a8..deb7fad 100644 Binary files a/presentation/presentation.pdf and b/presentation/presentation.pdf differ diff --git a/rhine-koans.cabal b/rhine-koans.cabal index 30b4a1c..7a1db7f 100644 --- a/rhine-koans.cabal +++ b/rhine-koans.cabal @@ -22,10 +22,11 @@ common opts ghc-options: -Wall default-language: GHC2021 build-depends: base >=4.16.4.0 && <4.20 - , rhine ^>= 1.3 + , rhine ^>= 1.4 , text ^>= 2.0 default-extensions: DataKinds + LambdaCase OverloadedStrings if flag(dev) ghc-options: -Werror @@ -58,6 +59,14 @@ library test-io silently ^>= 1.2 , temporary ^>= 1.3 +library test-gloss + import: opts + hs-source-dirs: generic/test-gloss + exposed-modules: TestGloss + build-depends: + rhine-gloss, + monad-schedule ^>= 0.2 + common basic-1-1-hello-rhine if flag(solution) hs-source-dirs: koans/basic/1/1-hello-rhine/solution @@ -329,3 +338,116 @@ test-suite basic-2-9-modularize-test type: exitcode-stdio-1.0 main-is: Test.hs hs-source-dirs: koans/basic/2/9-modularize/test + +common gloss + build-depends: + gloss, + rhine-gloss, + transformers ^>= 0.6, + random ^>= 1.2, + MonadRandom ^>= 0.6, + containers ^>= 0.6, + monad-schedule ^>= 0.2, + rhine-koans:test-gloss + +common ui-1-gloss-1-circle + import: gloss + if flag(solution) + hs-source-dirs: koans/ui/1-gloss/1-circle/solution + else + hs-source-dirs: koans/ui/1-gloss/1-circle + +executable ui-1-gloss-1-circle + import: exec, ui-1-gloss-1-circle + main-is: Main.hs + +test-suite ui-1-gloss-1-circle-test + import: test, ui-1-gloss-1-circle + type: exitcode-stdio-1.0 + main-is: Test.hs + hs-source-dirs: koans/ui/1-gloss/1-circle/test + +common ui-1-gloss-2-move + import: gloss + if flag(solution) + hs-source-dirs: koans/ui/1-gloss/2-move/solution + else + hs-source-dirs: koans/ui/1-gloss/2-move + +executable ui-1-gloss-2-move + import: exec, ui-1-gloss-2-move + main-is: Main.hs + +test-suite ui-1-gloss-2-move-test + import: test, ui-1-gloss-2-move + type: exitcode-stdio-1.0 + main-is: Test.hs + hs-source-dirs: koans/ui/1-gloss/2-move/test + +common ui-1-gloss-3-modularize + import: gloss + if flag(solution) + hs-source-dirs: koans/ui/1-gloss/3-modularize/solution + else + hs-source-dirs: koans/ui/1-gloss/3-modularize + +executable ui-1-gloss-3-modularize + import: exec, ui-1-gloss-3-modularize + main-is: Main.hs + +test-suite ui-1-gloss-3-modularize-test + import: test, ui-1-gloss-3-modularize + type: exitcode-stdio-1.0 + main-is: Test.hs + hs-source-dirs: koans/ui/1-gloss/3-modularize/test + +common ui-1-gloss-4-user-input + import: gloss + if flag(solution) + hs-source-dirs: koans/ui/1-gloss/4-user-input/solution + else + hs-source-dirs: koans/ui/1-gloss/4-user-input + +executable ui-1-gloss-4-user-input + import: exec, ui-1-gloss-4-user-input + main-is: Main.hs + +test-suite ui-1-gloss-4-user-input-test + import: test, ui-1-gloss-4-user-input + type: exitcode-stdio-1.0 + main-is: Test.hs + hs-source-dirs: koans/ui/1-gloss/4-user-input/test + +common ui-1-gloss-5-randomness + import: gloss + if flag(solution) + hs-source-dirs: koans/ui/1-gloss/5-randomness/solution + else + hs-source-dirs: koans/ui/1-gloss/5-randomness + +executable ui-1-gloss-5-randomness + import: exec, ui-1-gloss-5-randomness + main-is: Main.hs + +test-suite ui-1-gloss-5-randomness-test + import: test, ui-1-gloss-5-randomness + type: exitcode-stdio-1.0 + main-is: Test.hs + hs-source-dirs: koans/ui/1-gloss/5-randomness/test + +common ui-1-gloss-6-control-flow + import: gloss + if flag(solution) + hs-source-dirs: koans/ui/1-gloss/6-control-flow/solution + else + hs-source-dirs: koans/ui/1-gloss/6-control-flow + +executable ui-1-gloss-6-control-flow + import: exec, ui-1-gloss-6-control-flow + main-is: Main.hs + +test-suite ui-1-gloss-6-control-flow-test + import: test, ui-1-gloss-6-control-flow + type: exitcode-stdio-1.0 + main-is: Test.hs + hs-source-dirs: koans/ui/1-gloss/6-control-flow/test