Skip to main content

Building a tile game using JsPlumb's advanced drag handling

· 8 min read
Simon Porritt
JsPlumb core team

Many libraries offer the ability to drag elements around, but JsPlumb's drag handler is a little more advanced than most: there are a number of options you can set on JsPlumb to get very fine-grained control of how your users drag elements around. One of these - the constrainFunction - is the focus of our post today, and we'll show you how it can be used to make a simple tile game with the minimum of effort.

Constrain functions

In this first canvas, you can drag around each of the nodes as you please:

This is the default setup for JsPlumb. But let's say in this case want to impose the following constraints:

  • Blue nodes can only be dragged horizontally
  • Red nodes can only be dragged vertically

How can we do that? That's right! With a constrainFunction. We pass this to the dragOptions for our Surface:

dragOptions:{
constrainFunction:(desiredLoc, el, parentBounds, size, currentLoc) => {
const modelObject = el.jtk.node
return {
x:modelObject.type === "blue" ? desiredLoc.x : currentLoc.x,
y:modelObject.type === "blue" ? currentLoc.y : desiredLoc.y
}
}
}

Try dragging the elements here - you'll see they conform to the rules given above:

Our constrainFunction is given 5 pieces of information:

  • desiredLoc This is an object containing x and y values for where, in canvas coordinates, JsPlumb would like to drag the element to.
  • el This is the DOM element that is being dragged. We can retrieve the associated model object via el.jtk.node.
  • parentBounds This provides the size of the viewport; in some constrain scenarios this information is useful.
  • size The size of the element being dragged.
  • currentLoc An object containing x and y values for the element's current position.

In our implementation, we test the type of the element. For blue elements we allow the desired X location but keep the current Y; for red elements we keep the current X but allow the desired Y:

const modelObject = el.jtk.node
return {
x:modelObject.type === "blue" ? desiredLoc.x : currentLoc.x,
y:modelObject.type === "blue" ? currentLoc.y : desiredLoc.y
}

Mixer Console

One real world application that occurred to me as I was writing this is for the faders in a mixer:

For this one we have a little helper function that computes the bounds based on the type of fader:

const travel = {
crossfade:(desired, current) => {
return {
x:Math.min(190, Math.max(50, desired.x)),
y:current.y
}
},
channel:(desired, current) => {
return {
x:current.x,
y:Math.min(240, Math.max(80, desired.y))
}
}
}

and the constrainFunction is:

constrainFunction:(desiredLoc, el, parent, size, currentLoc) => {
const modelObject = el.jtk.node
return travel[modelObject.data.type](desiredLoc, currentLoc)
}

Traversing a path

Another very useful application for the ability to constrain dragging is traversing a path. In the canvas below you can drag the red marker horizontally and the vertical position will be calculated from the equation that defines the path (in this case, y=x^2):

0
0

In this example, our constrainFunction uses the function that defines the path to determine the Y value that corresponds to the current value of X:

constrainFunction:(desiredLoc, el, parent, size, currentLoc) => {
// pixels values
const start = 0, end = 300
// values in the coord system we're using
const min = -5, max = 5, xRange = max - min

// map the canvas pixel location to our chart's coordinate system
const mappedX = min + ((desiredLoc.x - start) / end * xRange)
// compute the Y value in our chart's coordinate system. Note: we negate the value here, because in a browser, Y increases as you go down the screen.
const mappedY = -fn(mappedX)
// map the Y value back to canvas pixel coordinates
const y = (start + (end * (mappedY - min) / xRange))

return {
x:desiredLoc.x, y
}

}

This approach works for any curve - here's y=x^3:

0
0

How about y=sin(x) ?

0
0

I like the look of these; I could go on for ages. But I promised a tile game, so let's talk about that.

Tiled image game

It's likely you've seen one of these before - an image cut up into tiles and rearranged, with one blank space. Your task is to move the tiles around and reconstruct the original image.

So, here's an image of a cow. You may recall this cow from some of our other posts. We've loaded up this cow and, using the canvas processing library we wrote for our ImageProcessor demo, we've cut it up into a 3x3 grid and drawn it out. You can drag the individual tiles around - go ahead and try:

This is the first step - an image broken up into a grid, and dragging is constrained to the grid. We loaded up the image and then determined how big our tiles should be based on our required grid:

const tileCount = 3

const image = new Image()
image.onload = () => {
const minAxis = Math.min(image.width, image.height),
tileSize = Math.floor(minAxis / tileCount)

}
image.src = "/path/to/cow.jpg"

We now split the image into a set of tiles, and each one of those tiles will be a node in our JsPlumb instance:

const promises = []
for (let x = 0; x < tileCount; x++) {
for (let y = 0; y < tileCount; y++) {
promises.push(cropImage(image, tileSize * x, tileSize * y, tileSize, tileSize))
}
}
Promise.all(promises).then(values => {
const dataUrls = values.map(imageToDataURL),
nodes = []

for (let x = 0; x < tileCount; x++) {
for (let y = 0; y < tileCount; y++) {
const url = dataUrls[(x * tileCount) + y]
nodes.push({
id: `${x}_${y}`,
url,
width: tileSize,
height: tileSize,
left:x * tileSize,
top:y * tileSize
})
}
}

toolkit.load({
data:{
nodes
}
})
})

That second section of code also runs inside the onload for the image. At that point we have the result shown above - an image, split into tiles, and rendered as individual nodes.

The next steps are:

  • Pick a random tile to leave blank
  • Randomly arrange the tiles

We're going to introduce two variables - emptyTileX and emptyTileY - to track the grid location of the empty tile.

// a random sort of the nodes.
nodes.sort((a, b) => Math.random() > 0.5 ? -1 : 1)
// random grid position for the x/y of the empty tile
let emptyTileX = Math.floor(Math.random() * tileCount)
let emptyTileY = Math.floor(Math.random() * tileCount)
// nodes are stored in a linear array; this gives us the array index
// for the empty tile
let tileToOmitIndex = (emptyTileX * tileCount) + emptyTileY

Lastly, we now need to update the nodes array to set new positions after they've been randomly ordered, and we also set blank value on our blank node:

for (let x = 0; x < tileCount; x++) {
for (let y = 0; y < tileCount; y++) {
const idx = (x * tileCount) + y
nodes[idx].left = x * tileSize
nodes[idx].top = y * tileSize
nodes[idx].blank = idx === tileToOmitIndex
}
}

This gives us:

Almost there. But we still need to constrain dragging. Our constrainFunction looks like this:

constrainFunction: (desiredLoc, el, parent, size, currentLoc) => {
// get the grid position for the target location and for the current location
const targetGridX = desiredLoc.x / tileSize
const targetGridY = desiredLoc.y / tileSize
const currentGridX = currentLoc.x / tileSize
const currentGridY = currentLoc.y / tileSize

// only tiles rthat are adjacent in one direction are candidates for being
// dragged into the empty slot
const dx = Math.abs(targetGridX - currentGridX)
const dy = Math.abs(targetGridY - currentGridY)
const isAdjacent = dx === 1 && dy !== 1 || dx !== 1 && dy === 1

if (!isAdjacent) {
return currentLoc
}

// finally, if the target is the empty slot, allow the tile to be dragged there.
if (targetGridX === emptyTileX && targetGridY === emptyTileY ) {
return desiredLoc
} else {
return currentLoc
}
}

We also want to ensure we keep track of the empty slot, so we have this event listener:

events:{
[EVENT_NODE_MOVE_END]:(p) => {
if (p.pos.y / tileSize === emptyTileY && p.pos.x / tileSize === emptyTileX) {
emptyTileY = p.originalPosition.y / tileSize
emptyTileX = p.originalPosition.x / tileSize
}
}
}

And here's the final result:

Too easy? How about this then:


Further reading


Start a free trial

Sending your evaluation request...

Interested in the concepts discussed in this article? Start a free trial and see how JsPlumb can help bring your ideas to market in record time.


Get in touch!

If you'd like to discuss any of the ideas/concepts in this article we'd love to hear from you - drop us a line at hello@jsplumbtoolkit.com.