Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to maintain circle velocity after colliding with a square?

I'm developing a game where the player is a circle and tiles are squares. The user moves the avatar (circle) with the keyboard, and should not be able to collide with tiles (squares).

Also, I would like the circle to slide along the square if they hit the corner, such that if the player keeps pressing the key to move in the same direction, they will slide along the square instead of getting stuck on it.

I've developed a full reproduction of the problem I'm facing here:

let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");

class Vec2 {
  constructor(x, y) {
    this.x = x || 0;
    this.y = y || 0;
  }

  distance(v) {
    let x = v.x - this.x;
    let y = v.y - this.y;

    return Math.sqrt(x * x + y * y);
  }

  magnitude() { 
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }

  dot(v) { 
    return this.x * v.x + this.y * v.y;
  }

  normalize() {
    let magnitude = this.magnitude();
    
    return new Vec2(this.x / magnitude, this.y / magnitude);
  }
  
  multiply(val) {
    return typeof val === "number" ? new Vec2(this.x * val, this.y * val) : new Vec2(this.x * val.x, this.y * val.y);
  }

  subtract(val) {
    return typeof val === "number" ? new Vec2(this.x - val, this.y - val) : new Vec2(this.x - val.x, this.y - val.y);
  }

  add(val) {
    return typeof val === "number" ? new Vec2(this.x + val, this.y + val) : new Vec2(this.x + val.x, this.y + val.y);
  }
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function drawCircle(xCenter, yCenter, radius) {
  ctx.beginPath();
  ctx.arc(xCenter, yCenter, radius, 0, 2 * Math.PI);
  ctx.fill();
}

function drawSquare(x, y, w, h) {
  ctx.beginPath();
  ctx.rect(x, y, w, h);
  ctx.stroke();
}

function circleRectangleCollision(cX, cY, cR, rX, rY, rW, rH) {
  let x = clamp(cX, rX, rX + rW);
  let y = clamp(cY, rY, rY + rH);

  let cPos = new Vec2(cX, cY);

  return cPos.distance(new Vec2(x, y)) < cR;
}

function getCircleRectangleDisplacement(rX, rY, rW, rH, cX, cY, cR, cVel) {
  let circle = new Vec2(cX, cY);

  let nearestX = Math.max(rX, Math.min(cX, rX + rW));
  let nearestY = Math.max(rY, Math.min(cY, rY + rH));    
  let dist = new Vec2(cX - nearestX, cY - nearestY);

  let tangentVel = dist.normalize().dot(cVel);

  // The original answer had `cVel.subtract(tangentVel * 2);` here
  // but that was giving me issues as well
  return cVel.add(tangentVel);
}

let circlePos = new Vec2(150, 80);
let squarePos = new Vec2(240, 110);

let circleR = 50;

let squareW = 100;
let squareH = 100;

let circleVel = new Vec2(5, 0);

draw = () => {
  ctx.fillStyle = "#b2c7ef";
  ctx.fillRect(0, 0, 800, 800); 

  ctx.fillStyle = "#ffffff";

  drawCircle(circlePos.x, circlePos.y, circleR);
  drawSquare(squarePos.x, squarePos.y, squareW, squareH);
}

update = () => {
  draw();

  if (circleRectangleCollision(circlePos.x, circlePos.y, circleR, squarePos.x, squarePos.y, squareW, squareH)) {
    circleVel = getCircleRectangleDisplacement(squarePos.x, squarePos.y, squareW, squareH, circlePos.x, circlePos.y, circleR, circleVel);
  }

  circlePos = circlePos.add(circleVel);
}

setInterval(update, 30);
canvas { display: flex; margin: 0 auto; }
<canvas width="800" height="800"></canvas>

If you run the snippet you'll see that the circle correctly moves around the square, but afterwards it moves down and to the right. I'm not sure why this is happening. It should just keep moving completely straight and to the right afterwards.

Unfortunately I'm not very good at math, so I'm having trouble figuring out why this is happening. I learned of the main algorithm through this answer, but also used the following answers as a reference: One Two Three

Another issue I noticed is that if you change the y position of circlePos from 80 to 240, then it still slides along the top of the square, instead of taking the more natural path of sliding along the bottom of the square. I'd like to fix this as well if possible.

In addition, ideally there shouldn't really be any sliding at all if the circle hits the tile straight on, if that makes sense. It should get stuck against the square in that case.

like image 439
Ryan Peschel Avatar asked Mar 29 '21 06:03

Ryan Peschel


1 Answers

I would suggest these changes:

Define two more methods in your class:

  crossProductZ(v) {
    return this.x * v.y - v.x * this.y;
  }
  
  perpendicular() {
    return new Vec2(this.y, -this.x);
  }

In getCircleRectangleDisplacement replace the return statement with:

return dist.perpendicular().normalize()
           .multiply(cVel.magnitude() * Math.sign(cVel.crossProductZ(dist)));

The idea is that the circle should move perpendicular to the line through the center of the circle and the hit point (which is dist). There are of course two directions on the perpendicular line: it should be the one that is at the same side of dist as is the current velocity vector. That way the circle will choose the right side of the square.

The magnitude of that move should be equal to the magnitude of the current velocity (so that there is no change in speed, just in direction).

Finally, also make this change to the update function:

  let nextCirclePos = circlePos.add(circleVel);
  if (circleRectangleCollision(nextCirclePos.x, nextCirclePos.y, circleR, squarePos.x, squarePos.y, squareW, squareH)) {
    let currentVel = getCircleRectangleDisplacement(squarePos.x, squarePos.y, squareW, squareH, circlePos.x, circlePos.y, circleR, circleVel);
    nextCirclePos = circlePos.add(currentVel);
  }
  circlePos = nextCirclePos;

The idea here is that we first do the move as usual (circleVel) and see if that would mean a collission. In that case we don't make that move. Instead we get the displacement from the current position.

And, we never update currentVel. That will guarantee that the movement will continue as before once the obstacle is out of the way.

In the snippet below these changes are made. In addition I added a second square in the path of the circle, and once the circle is out of view, I added a second run where the circle takes a different path:

let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");

class Vec2 {
  constructor(x, y) {
    this.x = x || 0;
    this.y = y || 0;
  }

  distance(v) {
    let x = v.x - this.x;
    let y = v.y - this.y;

    return Math.sqrt(x * x + y * y);
  }

  magnitude() { 
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }

  dot(v) { 
    return this.x * v.x + this.y * v.y;
  }

  normalize() {
    let magnitude = this.magnitude();
    
    return new Vec2(this.x / magnitude, this.y / magnitude);
  }
  
  multiply(val) {
    return typeof val === "number" ? new Vec2(this.x * val, this.y * val) : new Vec2(this.x * val.x, this.y * val.y);
  }

  subtract(val) {
    return typeof val === "number" ? new Vec2(this.x - val, this.y - val) : new Vec2(this.x - val.x, this.y - val.y);
  }

  add(val) {
    return typeof val === "number" ? new Vec2(this.x + val, this.y + val) : new Vec2(this.x + val.x, this.y + val.y);
  }
  
  crossProductZ(v) {
    return this.x * v.y - v.x * this.y;
  }
  
  perpendicular() {
    return new Vec2(this.y, -this.x);
  }
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function drawCircle(xCenter, yCenter, radius) {
  ctx.beginPath();
  ctx.arc(xCenter, yCenter, radius, 0, 2 * Math.PI);
  ctx.fill();
}

function drawSquare(x, y, w, h) {
  ctx.beginPath();
  ctx.rect(x, y, w, h);
  ctx.stroke();
}

function circleRectangleCollision(cX, cY, cR, rX, rY, rW, rH) {
  let x = clamp(cX, rX, rX + rW);
  let y = clamp(cY, rY, rY + rH);

  let cPos = new Vec2(cX, cY);

  return cPos.distance(new Vec2(x, y)) < cR;
}

function getCircleRectangleDisplacement(rX, rY, rW, rH, cX, cY, cR, cVel) {
  let circle = new Vec2(cX, cY);

  let nearestX = clamp(cX, rX, rX + rW);
  let nearestY = clamp(cY, rY, rY + rH);
  let dist = new Vec2(cX - nearestX, cY - nearestY);

  return dist.perpendicular().normalize().multiply(cVel.magnitude() * Math.sign(cVel.crossProductZ(dist)));
}

let circlePos = new Vec2(100, 80);
let squarePosList = [new Vec2(240, 110), new Vec2(480, -50)];

let circleR = 50;

let squareW = 100;
let squareH = 100;

let circleVel = new Vec2(5, 0);

draw = () => {
  ctx.fillStyle = "#b2c7ef";
  ctx.fillRect(0, 0, 800, 800); 

  ctx.fillStyle = "#ffffff";

  drawCircle(circlePos.x, circlePos.y, circleR);
  for (let squarePos of squarePosList) {
    drawSquare(squarePos.x, squarePos.y, squareW, squareH);
  }
}

update = () => {
  draw();

  let nextCirclePos = circlePos.add(circleVel);
  for (let squarePos of squarePosList) {
    if (circleRectangleCollision(nextCirclePos.x, nextCirclePos.y, circleR, squarePos.x, squarePos.y, squareW, squareH)) {
      let currentVel = getCircleRectangleDisplacement(squarePos.x, squarePos.y, squareW, squareH, circlePos.x, circlePos.y, circleR, circleVel);
      nextCirclePos = circlePos.add(currentVel);
      break; // we only deal with one collision (otherwise it becomes more complex)
    }
  }
  circlePos = nextCirclePos;
  if (circlePos.x > 800 + circleR) { // Out of view: Repeat the animation but with a diagonal direction
       circlePos = new Vec2(100, 400);
       circleVel = new Vec2(3.6, -3.6);
  }
}

let interval = setInterval(update, 30);
canvas { display: flex; margin: 0 auto; }
<canvas width="800" height="800"></canvas>

NB: You have some code repetition in the collision and displacement functions. They both calculate almost the same thing. This could be optimised.

like image 156
trincot Avatar answered Nov 15 '22 00:11

trincot