📜 ⬆️ ⬇️

Reactive programming

As you know, the functional approach to programming has its own specifics: in it we convert the data, not change them. But this imposes its own limitations, for example, when creating programs that actively interact with the user. In an imperative language, it is much easier to implement this behavior, because we can react to any events “in real time”, while in pure functional languages ​​we will have to postpone communication with the system until the very end. However, relatively recently, a new programming paradigm has begun to develop that solves this problem. And her name is Functional Reactive Programming (FRP). In this article I will try to show the basics of FRP using the example of writing a snake in Haskell using the reactive-banana library.

The rest of this article assumes that the reader is familiar with functors. If this is not the case, I strongly recommend that you read them, since the understanding of the entire article depends on it.

Main ideas


Two new data types appear in FRP: Event and Behavior . Both of these types are functors, and many actions on them will be performed by combinators of functors. We describe these types.

Event

An event is a stream of events that have an exact time stamp. You can imagine it as (just imagine, because in reality it’s not so simple):
type Event a = [(Time, a)] 
For example, an Event String can be a stream of chat users.
As already mentioned, Event belongs to the class of functors, which means we can perform some actions with it.
For example:
 ("Wellcome, " ++) <$> eusers 
will create a stream of greetings from users who have entered the chat.
')
Behavior

Behavior denotes a value that changes over time.
 type Behavior a = Time -> a 
This type is well suited for game objects, the snake in our game will be Behavior.
We can combine Behavior and Event using the apply function:
 apply :: Behavior t (a -> b) -> Event ta -> Event tb apply bf ex = [(time, bf time x) | (time, x) <- ex] 
As can be seen from this definition, apply applies the function inside Behavior to the Event, taking into account the time.

Let's go directly to the snake.

Game mechanics


For now, let's forget about reactive programming and get into the game mechanics. For a start, the types are:
 module Snake where type Segment = (Int, Int) type Pos = (Int, Int) type Snake = [Segment] 
One segment of the snake is a pair of coordinates, and the snake itself is a chain of these segments. Type Pos is needed only for convenience.

 startingSnake :: Snake startingSnake = [(10, 0), (11, 0), (12, 0)] wdth = 64 hdth = 48 
Create the initial position of the snake and the constant for the size of the playing field.

 moveTo :: Pos -> Snake -> Snake moveTo hs = if h /= head s then h : init s else s keepMoving :: Snake -> Snake keepMoving s = let (x, y) = head s (x', y') = s !! 1 in moveTo (2*x - x', 2*y - y') s ifDied :: Snake -> Bool ifDied s@((x, y):_) = x<0 || x>=wdth || y<0 || y>=hdth || head s `elem` tail s 
The moveTo function shifts the snake to the specified location, keepMoving continues to move, and ifDied checks to see if the snake has died from samoiding or colliding with boundaries.
This ends the mechanics, now comes the most difficult part - the logic of behavior.

Logics


We connect the necessary modules and describe some constants:
 {-# LANGUAGE ScopedTypeVariables #-} import Control.Monad (when) import System.IO import System.Random import Graphics.UI.SDL as S hiding (flip) import Graphics.Rendering.OpenGL hiding (Rect, get) import Reactive.Banana as R import Data.Word (Word32) import Snake screenWidth = wdth*10 screenHeight = hdth*10 screenBpp = 32 ticks = 1000 `div` 20 
screenWidth, screenHeight is the width and height of the screen, respectively; ticks is the number of milliseconds by which the frame will linger on the screen.

Now we define the inputs. Only two events will come to us from the outside world: a keystroke and a clock signal. So we need only two “slots” for events and they are created by the newAddHandler function:
 main :: IO () main = withInit [InitEverything] $ do initScreen sources <- (,) <$> newAddHandler <*> newAddHandler network <- compile $ setupNetwork sources actuate network eventLoop sources network 
In setupNetwork, a “network” will be built from Event and Behavior, compile will compile NetworkDescription into EventNetwork, and actuate will launch it. Events will be sent to the network from the eventLoop function, like signals to the brain from receptors.

 eventLoop :: (EventSource SDLKey, EventSource Word32) -> EventNetwork -> IO () eventLoop (essdl, estick) network = loop 0 Nothing where loop lt k = do s <- pollEvent t <- getTicks case s of (KeyDown (Keysym key _ _)) -> loop t (Just key) NoEvent -> do maybe (return ()) (fire essdl) k fire estick t loop t Nothing _ -> when (s /= Quit) (loop tk) 
This is the “receptor” of our program. fire essdl - runs the essdl event, which contains the name of the key, if it is pressed at all. estick runs regardless of user behavior and carries the time since the start of the program.

Here, by the way, is the transition from the EventSource, which returns newAddHandler, to the AddHandler:
 type EventSource a = (AddHandler a, a -> IO ()) addHandler :: EventSource a -> AddHandler a addHandler = fst fire :: EventSource a -> a -> IO () fire = snd 

Now we will begin the most responsible part: the description of a network of events.

 setupNetwork :: forall t. (EventSource SDLKey, EventSource Word32) -> NetworkDescription t () setupNetwork (essdl, estick) = do -- Keypress and tick events esdl <- fromAddHandler (addHandler essdl) etick <- fromAddHandler (addHandler estick) 
First we get the Event from those timer and keyboard events that we launched in eventLoop.

 let ekey = filterE (flip elem [SDLK_DOWN, SDLK_UP, SDLK_LEFT, SDLK_RIGHT]) esdl moveSnake :: SDLKey -> Snake -> Snake moveSnake ks = case k of SDLK_UP -> moveTo (x, y-1) s SDLK_DOWN -> moveTo (x, y+1) s SDLK_LEFT -> moveTo (x-1, y) s SDLK_RIGHT -> moveTo (x+1, y) s where (x, y) = head s 
Now create an event that means pressing the arrow - we do not need other keys. As you probably already guessed, filterE eliminates events that do not satisfy the predicate. moveSnake simply moves the snake, depending on the key pressed.

 brandom <- fromPoll randomFruits -- Snake let bsnake :: Behavior t Snake bsnake = accumB startingSnake $ (const startingSnake <$ edie) `union` (moveSnake <$> ekey) `union` (keepMoving <$ etick) `union` ((\s -> s ++ [last s]) <$ egot) edie = filterApply ((\s _ -> ifDied s) <$> bsnake) etick 
fromPoll implements another way of interacting with the real world, but it is different from what we used before. First, we get Behavior, not Event. And secondly, the action in fromPoll should not be expensive. For example, it is good to use fromPoll along with variables.
Next, we describe the snake with the help of accumB (note that the snake type is not just the Behavior Snake, but the Behavior t Snake. This has its deep meaning, which we don’t need to know).
accumB "collects" Behavior from Event and initial values:
 accumB :: a -> Event t (a -> a) -> Behavior ta 
That is, roughly speaking, when an event occurs, the function inside it will be applied to the current value.
For example:
 accumB "x" [(time1,(++"y")),(time2,(++"z"))] 
will create a Behavior, which at time1 will hold “xy”, and at time2 - “xyz”.
Another unknown function is the union. It merges events into one (if two events occur at the same time, the union gives priority to that of the first argument).
Now we can understand how bsnake works. First, the snake is equal to startingSnake, and then a number of changes happen to it:

The edie event fires when the snake is dead, and this is achieved using filterApply:
 filterApply :: Behavior t (a -> Bool) -> Event ta -> Event ta 
This function discards events that do not satisfy the predicate inside Behavior. As the name suggests, this is something like filter + apply.
Notice how often we use combinators of functors to turn anything into a function.

Now for the fruit:
 -- Fruits bfruit :: Behavior t Pos bfruit = stepper (hdth `div` 2, wdth `div` 2) (brandom <@ egot) egot = filterApply ((\fsr _ -> elem fs && notElem rs) <$> bfruit <*> bsnake <*> brandom) etick 
A new fruit with coordinates in brandom appears as soon as the snake has collected the current one. The combinator <@ "transfers" the contents of one Behavior to the Event, that is, in this case, the contents of the egot event will be replaced with a random coordinate from brandom. The new stepper function for us creates Behavior from Event and initial values, and its only difference from accumB is that the new Behavior event will not depend on the previous one.
The egot event is triggered on that timer signal when the snake picked up the fruit and the new fruit doesn’t get into her body.

 -- Counter ecount = accumE 0 $ ((+1) <$ egot) `union` ((const 0) <$ edie) 
ecount is a scoring event. It is easy to guess that accumE creates an Event, not a Behavior. The counter will be incremented by one at the egot event, and zeroed at edie.

 let edraw = apply ((,,) <$> bsnake <*> bfruit) etick 
edraw starts at every timer signal, and contains the current position of the snake and fruit.

Now it remains for the small: display the image on the screen.
 reactimate $ fmap drawScreen edraw reactimate $ fmap (flip setCaption [] . (++) "Snake. Points: " . show) ecount 
The reactimate function launches an IO action from the Event. drawScreen draws the screen, and setCaption changes the name of the window.
At this setupNetwork ends, and we can only add the missing functions.
Screen Initialization:
 initScreen = do glSetAttribute glDoubleBuffer 1 screen <- setVideoMode screenWidth screenHeight screenBpp [OpenGL] setCaption "Snake. Points: 0" [] clearColor $= Color4 0 0 0 0 matrixMode $= Projection loadIdentity ortho 0 (fromIntegral screenWidth) (fromIntegral screenHeight) 0 (-1) 1 matrixMode $= Modelview 0 loadIdentity 

Random position generator:
 randomFruits :: IO Pos randomFruits = (,) <$> (randomRIO (0, wdth-1)) <*> (randomRIO (0, hdth-1)) 

Well, and finally the drawing functions:
 showSquare :: (GLfloat, GLfloat, GLfloat, GLfloat) -> Pos -> IO () showSquare (r, g, b, a) (x, y) = do -- Move to offset translate $ Vector3 (fromIntegral x*10 :: GLfloat) (fromIntegral y*10) 0 -- Start quad renderPrimitive Quads $ do -- Set color color $ Color4 rgba -- Draw square vertex $ Vertex3 (0 :: GLfloat) 0 0 vertex $ Vertex3 (10 :: GLfloat) 0 0 vertex $ Vertex3 (10 :: GLfloat) 10 0 vertex $ Vertex3 (0 :: GLfloat) 10 0 loadIdentity showFruit :: Pos -> IO () showFruit = showSquare (0, 1, 0, 1) showSnake :: Snake -> IO () showSnake = mapM_ (showSquare (1, 1, 1, 1)) drawScreen (s, f, t) = do clear [ColorBuffer] showSnake s showFruit f glSwapBuffers t' <- getTicks when ((t'-t) < ticks) (delay $ ticks - t' + t) 

That's all. To compile, you'll need: reactive-banana, opengl, sdl. From here you can download program source files: minus.com/mZyZpD4Hx/1f

Conclusion


Using the example of a small game, I tried to show the basic principles of working with FRP: the presentation of program mechanics as a network of Event and Behavior, separation of input and output data. Even with such a simple program, you can see the advantages of FRP, for example, we did not have to start a type for the game state, as we would have done without using this paradigm. I hope that this article will help in the study of reactive programming and facilitate its understanding.

Links


hackage.haskell.org/package/reactive-banana - reactive-banana to hackage
github.com/HeinrichApfelmus/reactive-banana - project repository on github. There are examples.

Source: https://habr.com/ru/post/140719/


All Articles