top of page
Search
cedarcantab

Pixel Perfect Tile Collision in Phaser 3, Part 1


Cozy Physics Engine: Starting from the beginning


Now that I have gained some understanding of how Phaser's arcade physics engine deals with sprite vs tile collisions, and in particular how Phaser gets information on tiles at specific points / areas of the screen, I can start to build my own little physics engine, which I have dubbed Cozy Physics Engine.


In this series of posts, I will document a number of iterations, until I finally get to what I intended to create in the first place, which is to make a player climb "semi-slope" tiles (actually combination of partial tiles). I am documenting the "journey" as opposed to just the end result, primarily for my own benefit, to remind myself of why ended up coding the engine in the way I did. My fumblings might also be interesting to those in similar situations of wanting to dabble with their own simple physics engines.


As noted in the previous post, the core principles a physics engine is simple:

  1. detect collision - simple if AABB

  2. resolve collision - simple

  3. separate collided objects - simple

It is when you start implementing, that you realise the subtleties associated with even the simplest set-up - I hope you "enjoy" sharing the joys & frustrations I experienced during my journey (entry!) into the wonderful world of physics engines!



After thoughts


As well as looking through the Phaser 3 arcade physics engine code, I ended up reading a LOT of material (I have put links of the most directly relevant ones at the bottom of this post).


I learned a lot.


I also made what I thought were simplifying assumptions. In particular, I restricted the movement of the sprite to whole pixels rather than allow movement across sub-pixels (although internally the fractional movements are maintained). I made this decision because

  1. I personally found thinking in whole pixels = screen coordinates, easier to understand (perhaps because of my upbringing with home computers of the 1980's), and

  2. several articles and tutorials suggested such simplification

Interestingly, there is a fascinating article by the creators of Celeste (here) that suggests such simplification - and who can argue with the creators of Celeste!


However, in hindsight, perhaps because I relied quite heavily on many of Phaser's built-in methods which are fundamentally designed to work with sub-pixel movements, working in whole pixels probably introduced unnecessary complications. If I were to do this project from scratch, I would probably not do it in the same way.


Anyway, here goes.


Some simplifying assumptions


As this is just a fun little programming project with the aim of dealing with "slope" tiles (more accurately, partial tiles that are combined to look like slope tiles), I introduced some simplifying assumptions.


  1. Handle sprite vs tile collisions only (although sprite vs sprite collisions would be very easy to implement);

  2. physics body, as well as the partial tiles are all axis aligned rectangles (AABB)

  3. Restrict movement of sprite to whole pixels (i.e. do not allow sub-pixel movements)


For the rendering and stuff, I have relied entirely on Phaser, by using Phaser.GameObject.Sprite. It is only the "guts" of the sprite vs tile collision detection, resolution and response that I built from scratch.


Start with very simple environment - Whole Tiles. No Jumping. No gravity. No Bouncing of Walls


We start from the very beginning, to ease our way in. It is essentially recreating the guts of sprite vs tile physics engine from scratch to make sure that I have understood the core mechanics.


I have use the same map, tileset from the previous post and a 16x18 character for this exploration.


To try to make the experiments easier to manage, I have created:

  • CozyPhysics class: my very own physics engine. It's not a plug-in or anything like that. It is to be instantiated from the main game scene, and "stepped" from the game scene update loop. GameObject.Sprites objects and the tilemap for collision detection should be "added" to this physics world before it can be used.

    • this.world = new CozyPhysics(this)

    • this.player = new Player(this, 250, 180);

    • this.world.add(this.player);

    • this.world.setColliderLayer(this.platforms);

    • this.world.step(t,dt); (from the main game scene update loop)

  • CozyBody class: my very own version of a "physics body" which can be attached to a sprite. This should be added as this.body property of the gameobject (this.world.add(this.player) will do that. Once the body is attached, you can move the sprite around, in similar ways to Phaser Arcade Physics engine, like below:

    • this.body.setVelocityX(this.moveSpeed);

  • a simple Player class which allows you to move the player character around the screen in 4 directions using the cursor keys.

The CozyPhysics World


This is the class that houses all the CozyPhysics engine mechanics to handle the physics bodies in the physics world.


It only has a small number of basic properties.


class CozyPhysics {
  constructor(scene) {
    this.scene = scene;
    this.fixedDelta;
    this.bodies = [];
    this.colliderLayer;
    this.tileWidth;
    this.tileHeight;
    this.gravity = 0;
    this.bounds = new Phaser.Geom.Rectangle();
  }

Key critical properties are:

  • this.bodies: array that contains all the physics bodies

  • this.colliderLayer: this references the tilemap that collision detection is performed against (only handles one tilemap layer)

step(t, dt)

This loops through the this.bodies array. All it does is call the udpate method


step(t, dt) {
    this.fixedDelta = dt*0.001;
    for (let i = 0; i<this.bodies.length;i++) {
      let body = this.bodies[i];
      this.update(body, this.fixedDelta)
    }
  }

update(body, dt)

This method controls the key elements of the physics engine. In essence, the newPosition of the body is first calculated assuming that there is no colliding tiles, then collision checks are performed based on that position, and if necessary separation is carried out via the delta.x and delta.y variables.


The final line of this method checks whether the body is "on the ground" and sets body's property, body.onFloor to true if it is on the ground (ie at the bottom of the screen or on top of a tile). This check is carried out at the end of each update loop.


update(body, dt) {
  
    body.prev.copy(body.position); // copy current position to prev, before update for reference if needed
    
    body.computeVelocity(dt); // compute how much to move body
    body.newPosition.set(body.position.x+body.delta.x, body.position.y+body.delta.y); // where will body be? don't move the position directly, but stick it in newPosition
  
    this.checkWorldBounds(body); // check if body will hit the world bounds  
 // if there is velocity, call processX/Y to carry out collision detection, resolution, response in X and Y directions, in that order
    if (body.delta.x !==0) this.processX(body);
    if (body.delta.y !==0) this.processY(body);  
    
    body.position.add(body.delta); // update the body's position
    body.update(); // update the position properties of the body based on updated body.position
    body.postUpdate(); // feed the udpated body information to the parent game object
    body.checkGround(); // check if body is on ground
  }

The "delta" (ie the displacement of the body based on velocity x time elapsed) is calculated by the computeVelocity method.


computeVelocity(dt)

This methods takes the velocity and multiplies it by the time elapsed since the last update in order to get the "delta" by which the body will move in this update loop, if there is no collision.


I do some rounding and stuff to ensure that only whole numbers are fed to the physics engine (in hind sight, this is overly complicated and probably unnecessary - I did at the very end rewrite this to exclude this bit, but I will base this post documentation on the whole pixel version, because it is easier to explain).


 computeVelocity(dt) {

    this.delta.set(this.velocity.x * dt, this.velocity.y * dt);
    this.remainder.add(this.delta);
    this.delta.set(Math.round(this.remainder.x), Math.round(this.remainder.y));
    this.remainder.subtract(this.delta);
    
  }

processX() and processY()

This is where we handle the crucial collision detection. Looking at processX (logic for processY is the same, except it works in the vertical direction), it is the red highlighted code that handles the collision detection.


In essence, we first determine where to check for potential collisions. This is checking for horizontal direction, so if the body is moving to the right (ie body.delta.x>0) then we want to check to see if the right edge of the body is overlapping with any tiles. On the other hand, if we are moving left, we want to see if the left edge of the body is overlapping with any tiles.


In this version of the code, I am checking only the top and bottom of the vertical edges (ie if moving right, the top right hand corner and bottom right hand corner of where the body will be) for overlap, using getTileAtWorldXY at the respective pixel positions. This is different (inferior) from Phaser's arcade physics engine which gets all the tiles overlapping with the entire physics body and loops through them checking for collision.


Which "pixel" to check?

In determining which side of the body to check, I am subtracting 1 in calculating the right hand side. This is because (i) I am working in whole pixels, and (ii) I am using getTileAtWorld, as opposed to getTilesWithinWorldXY. In our case, say if the body's position is (0,0) (top left hand corner). Body's width is 16 (height is 18) and tile size is 16x16. We want to be checking for the tile in the top-left hand corner, and the one below. If we did not subtract 1, we would end up checking for tiles at (16,0) and (16, 17)*. getTileAtWorld(16,0) will return the tiles positioned second column from the right, which is not correct.


   const hitBoxSide = (body.delta.x > 0) ? body.newPosition.x + body.width-1 : body.newPosition.x;

* as you will see later, my version of body.right is defined as (body.position.x + body.width - 1) different from Phaser's body.right (which would be body.position.x+body.width).


Collision separation


If there are no overlapping tiles, then we return.


  processX(body) {

    const hitBoxSide = (body.delta.x > 0) ? body.newPosition.x + body.width-1 : body.newPosition.x;

    const tileToCheck = [   
      this.colliderLayer.getTileAtWorldXY(hitBoxSide, body.top),
      this.colliderLayer.getTileAtWorldXY(hitBoxSide, body.bottom)
    ].filter(n=>n);

    if (tileToCheck.length === 0) return
    
    if ((body.delta.x > 0) && (tileToCheck.some(e=>e.collideLeft)))
    { 
      body.delta.x = (Math.min(...tileToCheck.map(i=>i.pixelX)) - body.width) - body.position.x;
      body.responseX();
    }
    else if ((body.delta.x < 0) && (tileToCheck.some(e=>e.collideRight)))
    {
      body.delta.x = (Math.max(...tileToCheck.map(i=>i.right))) - body.position.x;
      body.responseX();
    }
    
  }

If there are any overlapping tiles, it is he blue highlighted code that carries out the collision separation, and responseX handles the collision response (in this particular case, collision response is simply to kill of the velocity to stop the body from moving; ie stop the body from moving into the tile).


Collision separation in the case of whole tiles is easy - we simply force the body to be aligned to the "cell" that is empty. More specifically, if we are moving right, and we have detected a collision (ie the right side of the body is overlapping with the left side of a tile), then the body must be moved left by the amount of "overlap" with the tile. The below line is calculating this "overlap" or "penetration"; ie the amount that the body can move.


body.delta.x = (Math.min(...tileToCheck.map(i=>i.pixelX)) - body.width) - body.position.x;


CozyBody


There are a lot of properties, mostly to do with determining the size and position of the body.



class CozyBody {
  constructor(world, parent) {

    this.world = world;
    ///////////////// Assumes the parent is unscaled & origin is (0.5, 0.5) ///////////////////////
    this.width = parent.width;
    this.height = parent.height;
    this.halfWidth = Math.floor(parent.width*0.5);
    this.halfHeight = Math.floor(parent.height*0.5);
    this.center = new Phaser.Math.Vector2(parent.x, parent.y);
    this.position = new Phaser.Math.Vector2(this.center.x - this.halfWidth, this.center.y - this.halfHeight);
    this.prev = new Phaser.Math.Vector2();
    this.newPosition = new Phaser.Math.Vector2();
    this.left = this.position.x;
    this.right = this.position.x + this.width - 1;
    this.top = this.position.y;
    this.bottom =  this.position.y + this.height - 1    

    this.velocity = new Phaser.Math.Vector2();
    this.delta = new Phaser.Math.Vector2();
    this.remainder = new Phaser.Math.Vector2();

    this.onFloor = false;
    this.parent = parent;
    
    this.offset = new Phaser.Math.Vector2();
   
  }

And that's the meat of my first CozyPhysics tile collision physics engine version 1. The codePen is available below.




Flaw with the above version


As mentioned above, the above version checks for existence of tile maps at only the four corners of the physics body. This is perfectly fine, as long as the physics body is equal or smaller than the tile.


In this simple example, the tiles are 16x16 in size whilst the physics body is 16x18 in size. What this means is that if the physics body is lined up so that its top row of pixels is "above" the tile and the bottom row of the body is below the tile, it can slip through, like below. We need to check for more points along the edge.











There are a number of different ways to do this.


The obvious way is to increase number of points. In the above version of the code, I check for existence of tiles using getTileAtWorldXY, ie at specific points. So we could simple add one more point, say, in the half way points between the top and bottom along the edge. That would solve the problem in this particular case. To make it make it more generic, you could even check for every single pixel along the colliding edge.


But a more elegant solution is to get all the tiles that overlap the relevant colliding edge, using getTilesWithinShape(line). Unfortunately, I have not been able to make this method work, but as mentioned in the previous post, I have written my own version of this method, (just to deal with line - does not deal with other shapes)


Only a very minor adjustment needs to be made to the processX and processY methods, as shown below.



 processX(body) {

    const hitBoxSide = (body.delta.x > 0) ? body.newPosition.x + body.width -1: body.newPosition.x;
    const tileToCheck = this.getTilesOnLine(hitBoxSide, body.top, hitBoxSide, body.bottom);

    if (tileToCheck.length > 0) {
      
      if ((body.delta.x > 0) && (tileToCheck.some(e=>e.collideLeft)))
      { 
        body.delta.x = (Math.min(...tileToCheck.map(i=>i.pixelX)) - body.width) - body.position.x;
        body.responseX();
      }
      else if ((body.delta.x < 0) && (tileToCheck.some(e=>e.collideRight)))
      {
        body.delta.x = (Math.max(...tileToCheck.map(i=>i.right))) - body.position.x;
        body.responseX();
      }
    }
  }

With this adjustment, there is no "slipping through".


This revised version of CozyPhysics engine is accessible below.




Useful references





27 views0 comments

Recent Posts

See All

p2 naive broadphase

var Broadphase = require('../collision/Broadphase'); module.exports = NaiveBroadphase; /** * Naive broadphase implementation. Does N^2...

sopiro motor constranit

import { Matrix2, Vector2 } from "./math.js"; import { RigidBody } from "./rigidbody.js"; import { Settings } from "./settings.js";...

Comments


記事: Blog2_Post
bottom of page