top of page
Search
cedarcantab

Pixel Perfect Tile Collision in Phaser 3, Part 0B

Updated: May 15, 2022

In this post, I continue to explore the insides of the Phaser arcade physics engine relating to sprite vs tile collisions.


Detecting colliding/overlapping Tiles


In the previous post, I skipped over the actual method to get the tiles that the physics body has overlapped. The key method used is GetTilesWithinWorldXY. Although I said that collision detection between AABB physics body and AABB tile is in principle simple, there are a lot of subtleties. I explore such subtleties using 16x16 pixel tiles and 16x18 arcade physics sprite.


GetTilesWithinWorldXY(x, y, w, h, null, tilemapLayer.scene.cameras.main, tilemapLayer.layer)


Consider the following six 16x16 pixel sized tiles laid out at the top left hand corner of the screen (i.e. the very top left in terms of worldXY is (0,0).


(The very simple 20x16 tilemap I created to experiment with is contained in the CodePen at the end of this post).


The indices of the Green, Brown, Blue, Orange tiles are tile.index 1, 2, 3, and 4 respectively.











In terms of the key properties of the tiles in the top left corner:

  • green (getTileAt(0,0)): index = 1, pixelX = 0, pixelY = 0, right = 16, bottom=16

  • blue (getTileAt(1,0)): index = 3, pixelX = 16, pixelY = 0, right = 32, bottom=16

  • brown (getTileAt(0,1)): index = 2, pixelX = 0, pixelY = 16, right = 16, bottom=32

  • orange (getTileAt(1,1)): index = 4, pixelX = 16, pixelY = 16, right = 32, bottom=32


Note that property "right" of the green tile "overlaps" with pixelX of the blue tile, and the property "bottom" of the green tile overlaps pixelY of the brown tile.



Reminder of the "size" properties of an arcade physics sprite

Assume a sprite whose size is width = 16, and height = 18 in pixel terms.









If you add this sprite to the scene at x=24, y=25 (sprite's origin being the center by default), the properties of the associated physics body would be as follows:

  • body.position = {x: 16, y: 16}

  • body.center = {x: 24, y: 25}

  • body.left = 16

  • body.right = 32

  • body.top = 16

  • body.bottom = 34

  • body.width = 16

  • body.height = 18


We particularly need to understand precisely what is happening at the right hand edge and the bottom edge.


body.right = body.position.x + body.width

body.bottom = body.position.y + body.height


getTilesWithinWorldXY(worldX, worldY, width, height [, filteringOptions] [, camera])

As a starter, getTilesWithinWorldXY(0,0,16,18) will return an array of 2 tiles, the first tile in the array being the green tile, and the second being the brown tile. So even though the width (=16) "overlaps" with pixelX of the blue and orange tiles, the getTiles method appears to ignore those (as you would expect).


If you try getTilesWithinWorldXY(16,0,16,18), this will return 2 tiles, this time tile index 3 (x=16, y=0), and 4 (x=16,y=16).


If you try getTilesWithinWorldXY(0,0,16,16), this will return only 1 tile (the green tile in the top left hand corner), as you would expect.


If we try getTilesWithinWorldXY(0,0,16.01,18), this will return an array of 4 tiles with indices 1,3,2,4 in that order, implying that the method scans from left to right, and then from top to bottom.


getTileAtWorldXY(worldX, worldY [, nonNull] [, camera] [, layer])

getTileAtWorldXY(8,8) will return tile.index = 1, tileX = 0, tileY = 0, tile.right = 16, tile.bottom = 16, as you would expect


What about at the borders?

getTileAtWorldXY(16,16) returns tile.index = 4, tile.pixelX=16, pixelY=16, tile.right = 32, tile.bottom=32


On the other hand, getTileAtWorldXY(15.999,16) will return the brown tile; tile.index = 2, tile.pixelX=0, pixelY=16, tile.right = 16, tile.bottom=32


Now that we kind of figured out the characteristic of this method, let's try something similar to that used by Phaser's arcade physics engine.


var mapData = this.platforms.getTilesWithinWorldXY(16,16,16+16,16+18, null, this.cameras.main, tilemapLayer);

This returns an array containing 2 sprites.

  • mapData[0]: tile.index=4, tile.left = 16, tile.top=16, tile.right=32, tile.bottom=32

  • mapData[1]: tile.index=-1, tile.left = 16, tile.top=32, tile.right=32, tile.bottom=48


The "top" of the physics body overlaps with the "bottom"of the tile in the top row, and the "left" of the physics body overlaps with the "right" of the tile to the left, they are ignored.


So how does getTilesWithinWorldXY work???

This calls worldToTileXY to get top-left corner of rectangle, rounded down to include partial tiles and bottom right corner then calls GetTilesWithin method.


 worldToTileXY(worldX, worldY, true, pointStart, camera, layer);

    var xStart = pointStart.x;
    var yStart = pointStart.y;

    //  Bottom right corner of the rect, rounded up to include partial tiles
    worldToTileXY(worldX + width, worldY + height, false, pointEnd, camera, layer);

    var xEnd = Math.ceil(pointEnd.x);
    var yEnd = Math.ceil(pointEnd.y);

    return GetTilesWithin(xStart, yStart, xEnd - xStart, yEnd - yStart, filteringOptions, layer);

worldToTileXY(worldX, worldY, [snapToFloor], [vec2], [camera], [layer])

Continuing the example above, for physics body:


First, the top-left hand corner coordinates converted to tile coordinates:


worldToTileXY(16,16) = {x: 1, y:1}


Then the bottom-right coordinate. Note the third parameter is set to false - this is the snapToFloor option (with the snapToFloor option set to true, the result would be {x: 2, y: 2}).


worldToTile(16+16 {width}, 16+18 {height}, false) = {x: 2, y: 2.125}


And then, note the use of Math.ceil which rounds up a number fo the next largest integer.

Math.ceil(2) = 2, but Math.ceil(2.125) = 3


Hence, body.right of precisely 16 (in pixel coordinate) is converted to tile coordinate.x of 1, hence the "width" parameter of GetTilesWithin will be 1 (being the xEnd - xStart). If x was 16.000001, xEndf would be 17, and hence width would be 2!!


Very neat! Very very clever!


Except, this will not work for our pixel based movements, where we would like x-coordinate of 16 to be treated as being in the second column from the left. Rather than doing the fancy rounding up and taking the difference we should simply search for all tiles based on the inclusive tile coordinates.


Hence the returned value will be the result of GetTilesWithin(1,1,1,2), giving you 2 tiles.


And just to complete our understanding of this method, we will look at how getTilesWithin (ie the tile coordinate version) works.


getTilesWithin([tileX], [tileY], [width], [height], [filteringOptions], [layer])

If you at the code behind this method, the first bit deals with checking the parameters to make sure they are clean (like the coordinates don't fall out the tilemap)


Then it loops through the "area", looking up an 2D array contained in the data property of the tilemap layer. This confirms how Phaser 3 reads the TILED tilemap information and stores it in a 2D array.


var results = [];

    for (var ty = tileY; ty < tileY + height; ty++)
    {
        for (var tx = tileX; tx < tileX + width; tx++)
        {
            var tile = layer.data[ty][tx];

            if (tile !== null)
            {
                if (isNotEmpty && tile.index === -1)
                {
                    continue;
                }

                if (isColliding && !tile.collides)
                {
                    continue;
                }

                if (hasInterestingFace && !tile.hasInterestingFace)
                {
                    continue;
                }

                results.push(tile);
            }
        }

Reminder of what a Phaser.Tilesmaps.Tilemap is

As the official documentation states, a Tilemap is just a container for Tilemap data. A map can have one or more tilemap layers.


Phaser.Tilemaps.TilemapLayer is a game object that renders LayerData from a Tilemap.


In essence, a tilemap consists of single or multiple tilemap layers, and each tilemap layer will have associated with it a "layer" property which is the Phaser.Tilemaps.LayerData.


Phaser.Tlemaps.LayerData in turn is a class for representing data about a layer. In turn, the "raw" data is assigned to a property called data, of this class. Hence, you can access information about individual tiles by looking at this data property, if the need arises.


Typically you would not go looking at this class directly since Phaser provides lots of methods associated with map and layers that does it all for you, but it is useful to know where the raw data exists.


For example, in the CodePen example at end of this post, the tilemap is assigned to variable called this.map, and the tilemap layer is assigned to this.platforms. The following will return 16 and 20 confirming that LayerData is indeed a 2D Javascript array.


console.log(this.platforms.layer.data.length,this.platforms.layer.data[0].length); // return 16, 20

And hence this.platforms.layer.data[0][0] will return the tile at the top left hand corner of the tilemap, and indeed if you try below, it will return 1 (the tile index).


console.log(this.platforms.layer.data[0][0].index); // return 1, the index# of the tile at top-left hand corner of layer

Phaser usefully provides a lot of properties containing basic information about a Tilemaps.LayerData. Below will, as expected return "Tile Layer 1", 20 and 16.


var layer = this.platforms.layer
console.log("name",layer.name, "width", layer.width, "height", layer.height)

What of the other getTiles methods?


There are lots of other interesting getTiles methods provided by Phaser.


getTilesWithinShape(shape [, filteringOptions] [, camera])


Could not get this thing to work at all.


For example, I would have expected below to return the same as getTilesWithinWorldXY(16, 16, 16, 18), but the result was an empty array.


var rect= Phaser.Geom.Rectangle(16, 16, 16,18);
var tiles = this.platforms.getTilesWithinShape(rect);

Or, trying it with a line segment, like below, also returns an empty array.


 var line= Phaser.Geom.Line(16,16,16,34);
 var tiles = this.platforms.getTilesWithinShape(line);

I really wanted to have the ability to get tiles overlapping a line. Hence I tried below. But this returns an empty array, which is not altogher surprising, given our understanding of the internal mechanics of getTilesWithinWorldXY (specifically, the conversion of the bottom-right hand corner tile coordinate would be the same as the top-left hand corner hence the code would end up looking for tiles overlapping a zero width column)


this.platforms.getTilesWithinWorldXY(16,16,0,18);

Of course, by introducing an "artificial" width like below, we would end up with the correct 2 tiles, effectively "overlapping with the line between (16,16) and (16,34)


getTilesWithinWorldXY(16, 16, 0.0001, 18);

#0: tile.index = 4, tile.pixelX = 16, tile.pixelY =16, tile.right =32 , tile.bottom = 32

#1: tile.index = -1, tile.pixelX = 16, tile.pixelY =32, tile.right =32 , tile.bottom = 48


My own version of getTilesWithinShape(line)

In my various experiments, I wanted to have the ability to get the tiles overlapping a line; armed with the above knowledge it was easy enough to write the function below.


The key difference are:

  • getTilesWithin is passed the tilecoordinates of the top-left hand corder and the bottom-left hand corner, as opposed to width and height

  • the for loop ending criteria of getTilesWithin method


  // get all the tiles overlapping with one edge of body
  // modified version of Phaser:s getTilesWithinShape(line) to care for whole pixel movements and boundaries
  getTilesOnLine(edgeX1, edgeY1, edgeX2, edgeY2) {
    // Get the Tile XY equivalent of the "top left" of edge
    var pointStart = this.colliderLayer.worldToTileXY(edgeX1, edgeY1, true, pointStart);
    // Get the Tile XY equivalent of the "bottom right" of edge
    var pointEnd = this.colliderLayer.worldToTileXY(edgeX2,edgeY2, true, pointEnd);
    return this.getTilesWithin(pointStart.x, pointStart.y, pointEnd.x, pointEnd.y);
  }

  getTilesWithin(xStart, yStart, xEnd, yEnd) {
    var layer = this.colliderLayer.layer;
    var results = [];

    for (var ty = yStart; ty <= yEnd; ty++)
    {
        for (var tx = xStart; tx <= xEnd; tx++)
        {
            var tile = layer.data[ty][tx];
            if (tile !== null)
            {
                if (tile.index === -1)
                {
                    continue;
                }
                results.push(tile);
            }
        }
    }
    return results;
  } 


Now that I have understood the basics of getting tile information at specific points or areas of the game screen, I can finally begin to think about creating our own tile collision system.


Below is the code I used to experiment with the various detection methods, including failure to get the getTilesWithinShape method working.




20 views0 comments

Yorumlar


記事: Blog2_Post
bottom of page