Today we’re going to make a game!
Our game is going to be based on JSJoust; where players have a controller which they keep as still as possible, whilst trying to force other players to move theirs. It’s all done in time to Johann Sebastian Bach (hence the JS).
You should definitely check it out. In fact, if you stop reading this post, and go looking for some PS Move controllers to play it – I’d definitely count that as a success.
We’re going to build a version that doesn’t have any Bach (so, just “Joust”), but using Javascript (so, “JoustJS”). We’ll show the player how far they’ve moved by changing the colour of their screen. When they’ve moved too much, we’ll show that they’ve lost.
Disclaimer time: if you break your phone, it wasn’t my fault.
Detecting sudden movements
We want to find out when a user makes a sudden movement. Rather than going straight for the orientation events, we’ll use mouse/touch position to make it easier to see what’s going on.
The rule we’re going to implement is: the cursor must cover no further than 300 pixels in 1.5 seconds.
First, we need to store the cursor position, so lets write some JavaScript:
class Point {
constructor (x, y, prev) {
this.x = x
this.y = y
this.timestamp = performance.now()
if (prev) prev.next = this
}
}
// store the most recent point
let current = null
// listen for mouse events
// ('touchmove' handler is omitted)
document.addEventListener('mousemove',
(e) => {
current = new Point(
e.pageX,
e.pageY,
current
)
},
false);
/*
* current:
* {
* x:…,
* y:…,
* timestamp:…
* }
*/
If you’re on a touch device, drag from the circle above to prevent scrolling (it’ll help you see what’s going on)
This javascript looks kind of weird… That’s because we’re using class, let and arrow function syntax from es2015. At some point in the future (probably not 2015) it’ll be totally cool to use this in browsers, but for now it’s safest to transform it into es5 using a tool such as Babel.
As well as storing the pointer coordinates, we’re storing the time that it was observed using performance.now
, allowing us to check if the point happened in a particular timespan.
We’re also passing our last current
point as an argument when we create the new one. If you look at the constructor of Point
, you can see that we’re linking the last point toward the new one, which means that we’ve got no way of accessing previous points (the .next
property of current
will be undefined
). This is pretty cool, because the browser can garbage collect our past points.
When we do want to store the data, we can hold a reference to a point, and when any subsequent points are added they’re accessible by traversing the .next
property.
function* points(p) {
do yield p
while (p = p.next)
}
// our starting point
let start = null
const traverse = (timestamp) => {
requestAnimationFrame(traverse)
if (!current) return
// move forward until we are
// within 1.5 seconds of now
for(start of points(start || current))
if(past.timestamp > timestamp - 1500)
break
}
requestAnimationFrame(traverse)
/**
* start → current:
*/
Right. Some things:
- We’ve made a generator function (
points
), which will let us iterate through our stored points in afor…of
loop. - We’re calling the
traverse
function withrequestAnimationFrame
, this calls it in time with the browser refresh rate (and pauses when the page is minimised/hidden). requestAnimationFrame
also passes atimestamp
value, which is kind of the equivalent of callingperformance.now
; allowing us to compare to the timestamps generated in Point.
Now that we’ve got access to the last 1.5 seconds of points, we can calculate how far the pointer has moved. We’ll define this as the diagonal length of the bounding box of points – that way it’s a bit more robust to wobbly/shaky input.
const range = (points) => {
let x_min, x_max, y_min, y_max, first = true;
for(let n of points) {
if(first){
x_min = x_max = n.x;
y_min = y_max = n.y;
first = false;
continue;
}
if (n.x < x_min) {x_min = n.x}
else if (n.x > x_max ) {x_max = n.x}
if (n.y < y_min) {y_min = n.y}
else if (n.y > y_max) {y_max = n.y}
}
return {
x: {min: x_min, max: x_max},
y: {min: y_min, max: y_max}
}
}
/*
* Range of points over last 1.5s:
* x: …, y = …
*/
const extent = (range) => ({
x: range.x.max - range.x.min,
y: range.y.max - range.y.min
})
/*
* Extent of those points:
* x = 0, y = 0
*/
const distance (e) =>
Math.sqrt(
Math.pow(e.x, 2) +
Math.pow(e.y, 2)
)
/*
* Distance covered:
* 0
*/
We can now implement our “too far” rule, first scaling and limiting the distance so that it’s a bit handier to play with.
const scale = (d) => d / 300
const limit = Math.min.bind(null, 1)
const tooFar = (s) => s >= 1
/*
* scaled: _
* tooFar: _
* */
Great.
Now we’ve got the data we need for the game, we just need to display it to the user.
Colours
Because we’re all about Christmas, the transition will be from green (all good) to red (too fast). We’ll need a function that converts our distance to a colour.
Colour interpolation can be kind of tricky; in our case, changing the rgb components of #f00
to #0f0
will result in a bit of a murky brown bit in the middle.
One way to deal with this is to use HSL colours, varying the hue from green (120) to red (0) and keeping the brightness and saturation constant. This also means the colour will sweep through yellow and orange (which are also pretty Christmassy).
// map [0,1] to [120,0]
const hue = d => (1-d) * 120
// generate a css colour
// (~~ is basically Math.floor)
const colour = d =>
`hsl(${~~hue(d)}, 100%, 45%)`
/*
* _
*
*/
Cool.
Movement
Right, we’ve got that all sorted; let’s start using the actual movement of our device instead of a pointer position.
We can listen for deviceorientation
events in our browser. This tells us which way the device is pointing through the event gamma
, alpha
& beta
properties.
{
gamma: _,
alpha: _,
beta: _
}
To emulate some device orientation events, drag the red circle above
We can use these properties to tell how far the device has rotated with the same functions we had before (with a little bit of updating to support a third property).
/*
* orientations (gamma & alpha):
*/
There’s a problem: when the alpha goes past 360 degrees, it’ll continue on from 0, which causes a big jump in distance, and makes us falsely detect a sudden movement.
We can get around this by mapping our values to 0→2Π
, then calling Math.Sin
on it. This means that 0
& 360
will both become 1
, and 180
will become -1
, and transitions between will be smooth. It’s not a terribly great approach for a few reasons – but it’ll do the job for now.
const PI2 = 2 * Math.PI
const convert = p => ({
alpha: Math.sin(PI2 * (p.alpha / 180)),
beta: Math.sin(PI2 * (p.beta / 360)),
gamma: Math.sin(PI2 * (p.gamma / 360))
})
/*
* converted:
* gamma = _
* alpha = _
* beta = _
*/
Now our distance doesn’t jump around. Though it’s a lot smaller than before (from -1
to 1
), so we can update our `scale` function to reflect that.
Game state
It’s not quite a game yet, for that we need to give players a way to start playing, and to lose. We can think of this as a series of states:
[ready, started, lost]
And some transitions between the states:
[start, lose]
There’s a sad absence of a won
state here. Because our devices aren’t connected together, we can’t really tell if there is one person left. I’ll be talking about this bit at Frontier Conf in March – you should come along.
Now we’ve got the model, we can implement our game with something like:
const READY = 1,
STARTED = 2,
LOST = 4
let state = READY
function start () {
if(state & READY | LOST) {
hideButton()
state = STARTED
}
}
function lose () {
if(state & STARTED) {
showButton()
state = LOST
}
}
// state = READY
Hooray!
Now that we’ve got a basic game, we can make it a little cooler by adding:
- More HTML & CSS – to make it look pretty
- Audio/vibrate feedback to the player – so players know when they’ve lost
- A Web App Manifest – so we can control how it looks when opened from a phone home screen
Play our game here
(source code on github)