2011-02-12

Game Programming With RX-JS

I'm quite excited by Microsoft's RX (Reactive Extensions) for JavaScript. I 'm also nostalgic for old school Commodore 64 games such as Bruce Lee, Archon and especially Wizard of Wor. Would'nt it be be cool to be able to play WoW(!) online.. So, why not give it a try and start writing it in RX-JS. 


First, I want to be able to control the Pixel Man (his real name remains unknown to me):



1. Keyboard Control


So, I started by defining some streams. This is actually the hardest part, so bear with me for a while.




function keyState(keyCode, value) {
  return Rx.Observable.FromArray([[]])
    .Merge(keyDowns(keyCode).Select(always([value]))
      .Merge(keyUps(keyCode).Select(always([])))
        .DistinctUntilChanged())
}

var allKeyUps = $(document).toObservable("keyup")
var allKeyDowns = $(document).toObservable("keydown")
function always(value) { 
  return function(_) { return value } }
function keyCodeIs(keyCode) { 
  return function(event) { return event.keyCode == keyCode} }
function keyUps(keyCode) { 
  return allKeyUps.Where(keyCodeIs(keyCode)) }
function keyDowns(keyCode) { 
  return allKeyDowns.Where(keyCodeIs(keyCode)) }

.. and I've got a stream of a given key's state, mapped into a single-element array in case the key is down, or the empty array if the key is up. Like below..

allKeyDowns           ...keydown......................
                         |
allKeyUps             ...|.............keyup..........
                         |             |              
keyState(key, "LOL")  ...["LOL"].......[].............

The keyState function starts with the empty array, and merges in all keyDowns as single-element arrays and all keyUps as empty arrays.

Then, I combine a bunch of these streams so that I can get the state of multiple keys are a set of values:


function multiKeyState(keyMap) {
  var streams = keyMap.map(function(pair) { 
    return keyState(pair[0], pair[1]) })
  return Rx.Observable.CombineLatestAsArray(streams)
}

I created some custom RX combinators for this:


Rx.Observable.CombineLatestAsArray = function(streams) {   
  return Rx.Observable.CombineAll(streams, function(s1, s2) { 
    return s1.CombineLatest(s2, concatArrays)})  
}

Rx.Observable.CombineAll = function(streams, combinator) {
  var stream = streams[0]
  for (var i = 1; i < streams.length; i++) {
    stream = combinator(stream, streams[i])
  }
  return stream;
}

function toArray(x) { return !x ? [] : (_.isArray(x) ? x : [x])}
function concatArrays(a1, a2) { 
  return toArray(a1).concat(toArray(a2)) }


This finally allows me to map the arrow keys into direction vectors that define where the Pixel Man shall go:

var keyMap = [
  [38, Point(0, -1)],
  [40, Point(0, 1)],
  [37, Point(-1, 0)],
  [39, Point(1, 0)]
]
var direction = 
  multiKeyState(keyMap).Where(atMostOne).Select(first)

The direction stream will then map my keystrokes like below:

  keydown:left......keyup:left....keydown:right
  |                 |             |
  Point(-1, 0)      undefined     Point(1, 0)

.. the Point function naturally returning simple x/y pairs.

2. Movement

To make the man move, I will start with a startPos, sample the direction stream every, say 100 ms, and increment the position by the currect direction. Simple as that:

function identity(x) { return x }
var ticker = Rx.Observable.Create(function(observer) { setInterval(observer.OnNext, 100) })
var movements = ticker.CombineLatest(direction, function(_, dir) { 
  return dir }).Where(identity)
var position = movements.Scan(startPos, function(pos, move) { 
  return pos.add(move.times(4)) })

I started by constructing a ticker stream that will generate an event every 100 ms. The movements stream was created by mapping the ticker events into the current direction using CombineLatest, and filtered out the undefined values by using Where(identity). Finally, the position stream is a kind of a sum of all movements, with the starting value of startPos.

At this point I might mention that the Point class that represents position and movement vectors (x/y pairs) has some methods for adding and multiplication, as can be seen in the defining function of the position stream. Please have a look at the full source code.

3. Graphics

So far this has been functional programming, without any side effects or mutable state. As you can see, I'm tracking keyboard state and Pixel Man position without any explicit state variables, thanks to RX!

To make the Pixel Man materialize and start moving, we need to select some graphics framework and assign side-effects to the position stream. So, I'll start by initializing Raphael and adding the Pixel Man on a black background:

var bounds = Rectangle(0, 0, 640, 480)
var r = Raphael(10, 10, bounds.width, bounds.height);
r.rect(bounds.x, bounds.y, bounds.width, bounds.height)
  .attr({fill : "#000"})
var startPos = Point(100, 100)
var man = r.image("man1.png", startPos.x, startPos.y, 40, 40)

Now that I've got the man on the stage and I've got the streams set up, I can make him move with a nice one-liner:

position.Subscribe(function (pos) { 
  man.attr({x : pos.x, y : pos.y}) })

4. Animation and Rotation

It was a bit dull to see the Pixel Man float around, even though he was in my control, so I added animation:

var animation = movements.Scan(1, function(prev, _) { 
  return prev % 2 + 1})
animation.Subscribe(function (index) { 
  man.attr({src : "man" + index + ".png"})}) 

Now that was cool, wasn't it? No poking around the code, just one new stream and a side effect. The stream maps movements in to an alternating series of 1's and 2's. The side-effect alters the image between the two png's that I've got.

Finally, I made the man look where he's going, instead of just looking right:

var angle = direction.Where(identity).Select(function(vec) { 
  return vec.getAngle()})
angle.Subscribe(function(angle) { 
  man.rotate(angle * 360 / (2 * Math.PI) + 180, true) })

Same thing here: a stream of angles and a side effect that rotates the Pixel Man.

5. Some fixing

I was happy with the simplicity and elegance of how I implemented animation and rotation. However, I wasn't so happy with the man turning upside-down when he was moving to the right.. So, I replaced the elegant code with something a little less elegant, but still quite simple:


var animAndDir = direction.Where(identity)
  .CombineLatest(animation, function(dir, anim) { 
    return {anim : anim, dir : dir}})
animAndDir.Subscribe(function(state) {
  var angle, basename
  if (state.dir == left) {
    basename = "man-left-"
    angle = 0
  } else {
    basename = "man-right-"
    angle = state.dir.getAngle() * 360 / (2 * Math.PI)
  }
  man.rotate(angle, true)
  man.attr({src : basename + (state.anim) + ".png"})
})

6. Conclusion

Easy wasn't it? After some learning and having mastered the keyboard state, it was quite trivial to make the Pixel Man move, rotate and animate.

Now I have a black screen with two keyboard-controlled Pixel Men. I've played it with my daughter and she coined it the Robot Game.  At 1 year and 11 months it's freaking awesome to be able to control a robot.

<1 week later>

After I wrote this, I hacked some more features into the game. Now there are two players and also some enemies there.

The code is available at https://github.com/raimohanska/worzone
Plx check out a live demo at http://juhajasatu.com/worzone/

Worx great in Chrome and Safarei, but a bit slowly in Firefox. Just like with the Lavalamp I blogged about earlierly. 

3 comments:

  1. Archon is my favorite too! Nice example btw.

    ReplyDelete
  2. This comment has been removed by a blog administrator.

    ReplyDelete