4

I'm making a canvas game using PureScript and I'm wondering what the best way to handle event listeners is, particularly running the callbacks within a custom monad stack. This is my game stack...

type BaseEffect e = Eff (canvas :: CANVAS, dom :: DOM, console :: CONSOLE | e)
type GameState = { canvas :: CanvasElement, context :: Context2D, angle :: Number }
type GameEffect e a = StateT GameState (BaseEffect e) a

What I'd like to do is change the "angle" property in the GameState when any key is pressed (just for development purposes so I can tweak the graphics). This is my callback function...

changeState :: forall e. Event -> GameEffect e Unit
changeState a = do
  modify \s -> s { angle = s.angle + 1.0 }
  liftEff $ log "keypress"
  pure unit

However addEventListener and eventListener look like they're meant to be used only with Eff, so the following won't type check...

addEventListener
  (EventType "keypress")
  (eventListener changeState)
  false
  ((elementToEventTarget <<< htmlElementToElement) bodyHtmlElement)

I thought I could define addEventListener and eventListener myself and import them using the foreign function interfaces (changing Eff to GameEffect). That typed checked, but caused a console error when I tried running in the browser.

foreign import addEventListener :: forall e. EventType -> EventListener e -> Boolean -> EventTarget -> GameEffect e Unit
foreign import eventListener :: forall e. (Event -> GameEffect e Unit) -> EventListener e

What's the best way to handle running callbacks within a monad stack?

Albtzrly
  • 924
  • 1
  • 6
  • 15

1 Answers1

4

I would use purescript-aff-coroutines for this. It means changing BaseEffect to Aff, but anything Eff can do Aff can do too:

import Prelude

import Control.Coroutine as CR
import Control.Coroutine.Aff as CRA
import Control.Monad.Aff (Aff)
import Control.Monad.Aff.AVar (AVAR)
import Control.Monad.Eff.Class (liftEff)
import Control.Monad.Eff.Console (CONSOLE, log)
import Control.Monad.Rec.Class (forever)
import Control.Monad.State (StateT, lift, modify)
import Data.Either (Either(..))

import DOM (DOM)
import DOM.Event.EventTarget (addEventListener, eventListener)
import DOM.Event.Types (Event, EventTarget, EventType(..))
import DOM.HTML.Types (HTMLElement, htmlElementToElement)
import DOM.Node.Types (elementToEventTarget)

import Graphics.Canvas (CANVAS, CanvasElement, Context2D)

type BaseEffect e = Aff (canvas :: CANVAS, dom :: DOM, console :: CONSOLE, avar :: AVAR | e)
type GameState = { canvas :: CanvasElement, context :: Context2D, angle :: Number }
type GameEffect e = StateT GameState (BaseEffect e)

changeState :: forall e. Event -> GameEffect e Unit
changeState a = do
  modify \s -> s { angle = s.angle + 1.0 }
  liftEff $ log "keypress"
  pure unit

eventProducer :: forall e. EventType -> EventTarget -> CR.Producer Event (GameEffect e) Unit
eventProducer eventType target =
  CRA.produce' \emit ->
    addEventListener eventType (eventListener (emit <<< Left)) false target

setupListener :: forall e. HTMLElement -> GameEffect e Unit
setupListener bodyHtmlElement = CR.runProcess $ consumer `CR.pullFrom` producer
  where
  producer :: CR.Producer Event (GameEffect e) Unit
  producer =
    eventProducer
      (EventType "keypress")
      ((elementToEventTarget <<< htmlElementToElement) bodyHtmlElement)
  consumer :: CR.Consumer Event (GameEffect e) Unit
  consumer = forever $ lift <<< changeState =<< CR.await

So in here the eventProducer function creates a coroutine producer for an event listener, and then setupListener does the the equivalent of the theoretical addEventListener usage you had above.

This works by creating a producer for the listener and then connecting it to a consumer that calls changeState when it receives an Event. Coroutine processes run with a monadic context, here being your GameEffect monad, which is why everything works out.

gb.
  • 4,629
  • 1
  • 20
  • 19
  • I was able to get it working, but only for a single keypress and the keypress seemed to block the graphics rendering. After the first keypress the consumer closes and the graphics render. I'm thinking I need to create a `Parallel` instance for GameEffect (wrapped with a newtype) so I can use `CR.runProcess $ connect producer consumer`. Does that sound like I'm on the right track? – Albtzrly Feb 07 '17 at 20:50
  • Oops! The `consumer` should have been defined like this: `forever $ lift <<< changeState =<< CR.await`, with `forever` coming from `Control.Monad.Rec.Class`. – gb. Feb 07 '17 at 23:59
  • Blocking is a little trickier to solve, as the easy answer would be ["just fork it"](https://github.com/slamdata/purescript-fork), however, there's no instance for forking `StateT`. You could probably come up with something with a `newtype` for `GameEffect` though. I'm a bit surprised it blocks rendering though, as that suggests calling `setupListener` in the render loop? It seems like you should be able to just do it once at the end of setup. Basically I don't have a good answer for this bit, sorry! – gb. Feb 08 '17 at 00:06
  • 1
    Thanks a lot for the help, I was able to get it working. I ended up putting the listener at the end of the setup (so it didn't block anything) and putting the keypress consumer and the graphics renderer in the forever loop. – Albtzrly Feb 09 '17 at 01:06