A list of different ways to deal with interactions (mouse clicks, keyboard input, network events) and rendering of graphics (gui elements, games).
All example code is written in janet.
Inspired by how elm used to do FRP (https://youtu.be/Ju4ICobPNfw)
Makes it possible to act on events and state in a functional way, allowing the user to use map/filter/reduce
on values over time.
State is a single value, e.g. a table (janet’s hashmap).
Rendering is a function taking the state as argument. No mutation is done during rendering.
Handling of interaction (e.g. mouse clicks) is done using s which can be resume
d in order to check for new values.
Then one can declare chains of function that will mutate state
depending on which s have changed since last time.
- frp/reduce
- acts like reduce but on an
<unknown>
- acts like reduce but on an
- frp/map
- acts like map but on an
<unknown>
, i.e. transforming values using a function
- acts like map but on an
- - a collection over time - values are produced inside it, e.g. mouse clicks - you can “pull” values from it
- resume (on janet fibers)
- Pulls on a
<unknown>
, causing modification of the state - This means no functions are called until they are needed
- Pulls on a
- separation of state and rendering (this is already done in the
def state
part) - as long as one has the same start state and events, it’s possible to replay the progression of the program
- efficient, it is “lazy” in the sense that it will only run following functions if any values have been produced by
<unknown>
- specifically, easy to only rerender when something changed
- a bit hard to understand
- functions acting on
<unknown>
vs regular functions are kind of similar, possibly easy to mix up - having to think about
<unknown>
as a lazy list over time is possibly a bit mind boggling
# the state of the program
(def state {:buttons [{:hitbox @[810 30 100 100]
:render |(draw-rectangle-rec ($ :hitbox) :red)
:f |(print "red!")}
{:hitbox @[860 0 100 100]
:render |(draw-rectangle-rec ($ :hitbox) :blue)
:f |(print "blue!")}]})
# first run frp/reduce (which is similar to reduce)
# so for each click in `mouse-click` (an <unknown> which will yield mouse clicks)
# filter out the buttons that that were hit. `pos` is mouse position from `mouse-click`
# $ is an element in `(state :buttons)`
(->> (frp/reduce (fn [buttons pos]
(filter |(hit-me? pos ($ :hitbox)) buttons))
(state :buttons)
mouse-click) # mouse-click is an <unknown>
# after the above step, we end up with an array of
# all buttons that were clicked.
# so we select the last one (same as the one rendered last)
(frp/map last)
# then, if there was a hit, we run the function :f
(frp/map |(when-let [f (get $ :f)] (f)))
# resume just "gets the ball rolling" / pulls on the above
# i.e. run all of the above, check for clicks, if no clicks happened
# nothing will happen. it won't run `filter` or `last` or anything after it
resume))
Calls a list of functions, with each function rendering to its own render texture. Each function can contain both rendering and interaction logic.
This means that the first function will be first to react to e.g. mouse clicks, as to disallow for later functions to use the same input. The most obvious case would be when two gui elements overlap, and you only want to trigger the one on top. In this situation, you would have a list of functions:
(def fns [button-above
button-below])
(def render-textures @{button-above ...
button-below ...})
(loop [f :in fns]
(begin-texture-mode (render-textures f))
(f) # checks for inputs & renders
(end-texture-mode))
If just rendering normally, this would mean that button-below
renders on top of button-above
. To avoid this, they render to a texture.
Then afterwards, all render textures are rendered in reverse order:
(map render-texture (map render-textures (reverse fns)))
- the functions can contain arbitrary code, very few restrictions
- functions look very similar to “naive” raylib code, i.e. easy to just start creating components
- performance costs of render textures (gpu memory, 2x the rendering)
- need to declare size of render textures ahead of time
- no built in way to deal with caching / avoiding rerendering / recalculations
- consuming mouse clicks is up to the function, i.e. easy to accidentally eat input / forget to eat input
Put a bunch of tables into a list, each table containing an update function and a render function (similar structure as the state in the FRP example). Loop through all the update-functions from bottom to top, then loop through all rendering functions from top to bottom.
(def objs [@{:id :below
:render (fn [self] ...)
:update (fn [self] ...)}
@{:id :on-top
:render (fn [self] ...)
:update (fn [self] ...)}])
Alternatively, put a :z-value on each table, in order to avoid being dependent on the order of the array.
(def objs [@{:id :on-top
:z 1 # despite being before, rendering will be sorted by :z
:render (fn [self] ...) # and :on-top will be rendered above :below
:update (fn [self] ...)}
@{:id :below
:z 0
:render (fn [self] ...)
:update (fn [self] ...)}]
- easy to understand, especially for people having experience with OOP
- pretty standard, e.g. unity works in a similar way
- no ceremony around mutation, just modify the “object” tables
- no automatic caching / avoidance of rerendering
- a lot of structure, but not much value from it
- consuming mouse clicks is up to the function, i.e. easy to accidentally eat input / forget to eat input
(def callbacks @{})
(defn push-cb
[k f]
(update callbacks k |(array/push (or @[] $) f)))
(def objs [@{:hitbox @[810 30 100 100]
:render |(draw-rectangle-rec ($ :hitbox) :red)
:update |(when (and (mouse-button-down? 0)
(in-rec? (get-mouse-position)
($ :hitbox)))
(push-cb :click ($ :f)))
:f |(print "red!")}
@{:hitbox @[860 0 100 100]
:render |(draw-rectangle-rec ($ :hitbox) :blue)
:update |(when (and (mouse-button-down? 0)
(in-rec? (get-mouse-position)
($ :hitbox)))
(push-cb :click ($ :f)))
:f |(print "blue!")}])
(varfn draw-frame
[dt]
(loop [b :in objs]
(:update b))
(loop [[_ cbs] :pairs callbacks
:when (not (empty? cbs))]
# when multiple callbacks with same key triggered
# we only call the last one
((last cbs))
(array/clear cbs))
(loop [b :in objs]
(:render b)))