// the collision detection logic based on : https://www.youtube.com/watch?v=kVBABNw1jaE

class Actor extends Phaser.Physics.Arcade.Sprite {
  constructor(scene, x, y) {
    super(scene, x, y, 'dude');
    this.scene = scene;
    scene.add.existing(this);
    scene.physics.add.existing(this);
    this.body.setSize(20, 32).setOffset(6,16);
    this.pos = new Phaser.Math.Vector2(x,y);    
    this.vel = new Phaser.Math.Vector2();
    this.gravity = 8;
   
    this.rays = new RayCaster(scene, x, y);
    this.rays.setColliderLayer(this.scene.objects)
    this.bottomLeft = new Phaser.Math.Vector2();
    this.bottomRight = new Phaser.Math.Vector2();
    this.topLeft = new Phaser.Math.Vector2();
    this.topRight = new Phaser.Math.Vector2();
    this.horizontalRaySpacing = new Phaser.Math.Vector2(0, this.body.halfHeight);
		this.verticalRaySpacing = new Phaser.Math.Vector2(this.body.halfWidth,0);
 
    this.touching = {left: false, right: false, above: false, below: false};
    this.skinDepth = 0.1;
    this.cursors = this.scene.input.keyboard.createCursorKeys();
    this.jumpVelocity = -6;

    this.maxClimbAngle = Math.PI/4;
  } 

  update(t, d) {
    this.vel.y += this.gravity * d * 0.001;
    this.move();

    if (this.touching.below || this.touching.above)  this.vel.y = 0;
    this.handleControls();
  }

 move() {
    this.updateRaycastOrigins();
    this.touchingReset();
    if (this.vel.y !== 0) this.verticalCollisions();
    if (this.vel.x !== 0) this.horizontalCollisions();
 
    this.pos.add(this.vel);
    this.setPosition(this.pos.x, this.pos.y)
  } 
  
  handleControls() {
    if (this.cursors.left.isDown) {
      this.vel.x = -2
      this.anims.play('left', true)
    } else  if (this.cursors.right.isDown) {
      this.vel.x = 2
      this.anims.play('right', true)
    } else {
      this.vel.x = 0;
      this.anims.stop()
    }

    if ((this.cursors.up.isDown && this.touching.below)) {
      this.vel.y = this.jumpVelocity;
    }

  }
 
  horizontalCollisions() {
    let directionX = Math.sign(this.vel.x); // if +ve, moving downwards
    let rayLength = Math.abs(this.vel.x) + this.skinDepth;
    let origin = directionX > 0 ? this.bottomRight.clone() : this.bottomLeft.clone();
    for (let i = 0; i < 3; i++) {
      const hit = this.rays.castRay(origin, directionX > 0 ? 0 : -Math.PI, rayLength); // check sideways!!!!!
      if (hit) {
        if (i === 0 && Math.abs(hit.slope) < this.maxClimbAngle) {
          this.climbSlope(this.vel, hit.tangent)
        }
        this.vel.x = (hit.fraction*rayLength - this.skinDepth) * directionX;
        rayLength *= hit.fraction;
        this.touching.left = directionX < 0;
        this.touching.right = directionX > 0;
      }
      origin.subtract(this.horizontalRaySpacing)
    }
  }

  climbSlope(vel, tangent) {
    let speed = Math.abs(this.vel.x);
    this.vel.set(tangent.x*speed, tangent.y*speed) 
    this.touching.below = true;
  }
  
  verticalCollisions() {
    let directionY = Math.sign(this.vel.y); // if +ve, moving downwards
    let rayLength = Math.abs(this.vel.y) + this.skinDepth;
    let origin = directionY > 0 ? this.bottomLeft.clone() : this.topLeft.clone();
    origin.x += this.vel.x
    for (let i = 0; i < 3; i++) {
      const hit = this.rays.castRay(origin, Math.PI/2 * directionY, rayLength);
      if (hit) {
        this.vel.y = (hit.fraction*rayLength - this.skinDepth) * directionY;
        rayLength *= hit.fraction;
        this.touching.below = directionY > 0;
        this.touching.above = directionY < 0;
      }
      origin.add(this.verticalRaySpacing)
    }
  }
  
  touchingReset() {
    this.touching = {left: false, right: false, above: false, below: false};
  }
  
  updateRaycastOrigins() {
    this.topLeft = this.getTopLeft();
		this.topRight = this.getTopRight();
		this.bottomLeft = this.getBottomLeft();
		this.bottomRight = this.getBottomRight();
	}

  getTopLeft()  {
    var output = new Phaser.Math.Vector2(
      this.body.left + this.skinDepth,
      this.body.top + this.skinDepth
    )
    return output;
  }
 
  getTopRight() {
    var output = new Phaser.Math.Vector2(
      this.body.right - this.skinDepth,
      this.body.top + this.skinDepth
    )
    return output;
  }
  
  getBottomLeft() {
    var output = new Phaser.Math.Vector2(
      this.body.left + this.skinDepth,
      this.body.bottom - this.skinDepth
    )
    return output;  
  }
  
  getBottomRight()    {
    var output = new Phaser.Math.Vector2(
      this.body.right - this.skinDepth,
      this.body.bottom - this.skinDepth
    )
    return output;
  }
  
}

class RaycastHit2D {
  constructor() {
    this.point = new Phaser.Math.Vector2();
    this.fraction = null;
    this.tangent = new Phaser.Math.Vector2();
    this.slope = null;
    this.normal = new Phaser.Math.Vector2();
  }
  
  copy(src) {
    this.point = src.point;
    this.fraction = src.fraction;
    this.tangent = src.tangent;
    this.slope = src.slope;
    this.normal = src.normal;
  }
  
  set() {
    this.point = null;
    this.fraction = null;
    this.tangent = null;
    this.slope = null;
    this.normal = null;
  }

}

class RayCaster {
  constructor(scene, x, y) {
    this.scene = scene;
    this.pos = new Phaser.Math.Vector2(x,y);
    this.angle = 0;
    this.fov = Math.PI / 4; // set default to 45 degrees
    this.ray = new Phaser.Geom.Line(this.x, this.y, this.x + 1, this.y); // line of unit length
    this.raysLayer;
    this.debug = true;
  }

  setColliderLayer(colliders) {
    this.raysLayer = colliders
  }
  
  setCone(fov = Math.PI / 4) {
    this.fov = fov; 
  }
  
  setTo(x,y, angle) {
    this.pos.x = x;
    this.pos.y = y;
    this.angle = angle;
  }
  
  cast() {  
    return this.castRay(this.angle)
  }
  
  castCone() {
    for (let i = this.angle-this.fov/2; i < this.angle + this.fov/2; i += Math.PI/4/30 ) {
      this.castRay(this.pos, i)
    };
  }
 
  castRay(origin, angle, distance) {
    let out = new RaycastHit2D();
    if (distance === undefined) { distance = null };
    Phaser.Geom.Line.SetToAngle(this.ray, origin.x, origin.y, angle, distance !== null ? distance : 1); 
    out = this.GetLineToPolygon(this.ray, this.raysLayer);
    if ((out === null) || (distance !== null && out.fraction > 1) ) return null;
    return out;
  }
  
  GetLineToPolygon (line, polygons) {
    var out = new RaycastHit2D();
    if (!Array.isArray(polygons)) {
      polygons = [ polygons ];
    }
    var closestIntersect = false;
  
    out.set();
    var tempIntersect = new RaycastHit2D();
    for (var i = 0; i < polygons.length; i++) {
      if (this.GetLineToPoints(line, polygons[i].points, tempIntersect)) {
        if (!closestIntersect || tempIntersect.fraction < out.fraction) {
          out.copy(tempIntersect);
          closestIntersect = true;
        }
      }
    }
    return (closestIntersect) ? out : null;
  };
  
  GetLineToPoints(line, points, out) {
    if (out === undefined) { out = new RaycastHit2D(); }
    var closestIntersect = false;

    out.set();
    var tempIntersect = new RaycastHit2D();
    var prev = points[0];
    for (var i = 1; i < points.length; i++) {
      var current = points[i];
      var segment = new Phaser.Geom.Line(prev.x, prev.y, current.x, current.y);
      prev = current;
      if (this.LineToLine(line, segment, tempIntersect)) {
        if (!closestIntersect || tempIntersect.fraction < out.fraction) {
          out.copy(tempIntersect);
          closestIntersect = true;
        }
      }
    }
    return (closestIntersect) ? out : null;
  };
  
  LineToLine(line1, line2, out) {
    if (out === undefined) { out = new RaycastHit2D(); }

    var x1 = line1.x1;
    var y1 = line1.y1;
    var x2 = line1.x2;
    var y2 = line1.y2;

    var x3 = line2.x1;
    var y3 = line2.y1;
    var x4 = line2.x2;
    var y4 = line2.y2;

    var numA = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3);
    var numB = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3);
    var deNom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);

    if (deNom === 0)    {
      return false;
    }
    var uA = numA / deNom;
    var uB = numB / deNom;
    if (uA >= 0 && uB >= 0 && uB <= 1)    {
      out.point = new Phaser.Math.Vector2(x1 + (uA * (x2 - x1)), y1 + (uA * (y2 - y1)));
      out.fraction = uA;
      out.tangent = new Phaser.Math.Vector2(line2.x2 -line2.x1, line2.y2 - line2.y1).normalize();
      out.slope = Phaser.Geom.Line.Angle(line2);
      out.normal = Phaser.Geom.Line.GetNormal(line2);
      return out;      
    }
    return false;
  };
}

class main extends Phaser.Scene {
  constructor() {
    super({key: "main"});
    this.objects = [];
  }
    
  preload () {
    this.load.setBaseURL('https://raw.githubusercontent.com/photonstorm/phaser3-examples/master/public/src/games/firstgame/');
    this.load.image('sky', 'assets/sky.png');
    this.load.image('ground', 'assets/platform.png');
    this.load.spritesheet('dude', 'assets/dude.png', { frameWidth: 32, frameHeight: 48 });
  }

  create() {
    this.graphics = this.add.graphics();
    this.createAnims();
    this.player = new Actor(this, 250, 100, 30, 30, 0x6666ff);
    this.objects.push(new Phaser.Geom.Polygon([30,410,750,410,750,450,30,450,30,410])); // ground
    this.objects.push(new Phaser.Geom.Polygon([30,100,60,100,60,430,30,430,30,100])); // left wall
    this.objects.push(new Phaser.Geom.Polygon([750,100,780,100,780,450,750,450,750,100])); // right wall
    this.objects.push(new Phaser.Geom.Polygon([390,450,760,300,790,330,420,480,390,450])); // slope
    this.objects.push(new Phaser.Geom.Polygon([100,300,200,300,200,330,100,330,100,300])); // platform
    this.objects.forEach(s => {
      this.graphics.lineStyle(3, '0x0000ff').strokePoints(s.points);
      this.graphics.fillStyle('0x00ff00').fillPoints(s.points);
    });
    this.add.text(0,0,["Left & Right Arrow Keys to Move", "Up Arrow Key to Jump"])
  }

  update(t,d)  {
   this.player.update(t,d)
  }

  createAnims() {
    this.anims.create({
      key: 'left',
      frames: this.anims.generateFrameNumbers('dude', { start: 0, end: 3 }),
      frameRate: 10,
      repeat: -1
    });
    this.anims.create({
      key: 'turn',
      frames: [ { key: 'dude', frame: 4 } ],
      frameRate: 20
    });
    this.anims.create({
      key: 'right',
      frames: this.anims.generateFrameNumbers('dude', { start: 5, end: 8 }),
      frameRate: 10,
      repeat: -1
    });
  }
}

const config = {
  width: 800,
  height: 500,
 
  pixelArt: true,
  physics: {
    default: "arcade",
    arcade: {
      debug: true
    }
  },
  scene: [main]
};

SCREEN_WIDTH = config.width;
SCREEN_HEIGHT = config.height;

const game = new Phaser.Game(config);
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/phaser/3.55.2/phaser.min.js