I am creating an Air Hockey game using JavaScript, in which, I am having problems in implementing the collision detection algorithm perfectly. My algorithm takes ‘disc’ which is held by the user’s mouse, and ‘puck’ which is being hit. Note that both can be in motion. Also note that both disc and puck are 2d circles.
I am providing the HTML, CSS and JS files. The collision detection function is written in main.js
(PC only) Run using live-server on VS Code. Click on the bottom disc to interact.
index.html
<!DOCTYPE html>
<html lang = "en">
<head>
<meta charset = "UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Air Hockey</title>
<link href = "stylesheet.css", rel = "stylesheet">
<link rel = 'icon' type = 'image/x-icon' href = ''>
</head>
<body scroll = "no" style = "overflow: hidden;">
<canvas id = "ca"></canvas>
<script type = "module" src = "main.js"></script>
</body>
</html>
main.js
/** @type {HTMLCanvasElement} */
let canvas1 = document.getElementById('ca');
canvas1.height = canvas1.clientHeight;
canvas1.width = canvas1.clientWidth;
let height = canvas1.height, width = canvas1.width;
let ctx = canvas1.getContext('2d');
ctx.strokeStyle = 'black';
ctx.lineWidth = 5;
// Parameters
const timeStep = 1000/100;
let last = 0; // last time step
const rect = canvas1.getBoundingClientRect();
let inGame = {'val' : false} // is canvas active
let cursor = {'X' : 0, 'Y' : 0}; // cursor positions
let prevCursor = {'X' : 0, 'Y' : 0}; // previous cursor positions
let forceFactor = 1.5;
// event listeners
canvas1.addEventListener('click', (e) => { // click on disc to move, click again to quit
if(!inGame['val']) {
inGame['val'] = true;
cursor['X'] = prevCursor['X'] = e.clientX;
cursor['Y'] = prevCursor['Y'] = e.clientY;
}
else
inGame['val'] = false;
})
document.addEventListener('mousemove', (e) => {
if(inGame['val']) {
prevCursor['X'] = cursor['X'];
prevCursor['Y'] = cursor['Y'];
cursor['X'] = e.clientX;
cursor['Y'] = e.clientY;
}
})
// Main
class Disc {
constructor(width, height) { // width and height are same as diameter
this.x = 0;
this.y = 0;
this.prevX = 0;
this.prevY = 0;
this.height = height;
this.width = width;
}
reset(x, y) {
this.x = x;
this.y = y;
this.prevX = x;
this.prevY = y;
}
upperBoundary() {
return this.y < this.height*0.5;
}
lowerBoundary() {
return this.y + this.height*0.5 > height;
}
leftBoundary() {
return this.x < this.width*0.5;
}
rightBoundary() {
return this.x + this.width*0.5 > width;
}
boundaryCheck() {
if(this.rightBoundary())
this.x = width - this.width*0.5;
else if(this.leftBoundary())
this.x = this.width*0.5;
if(this.lowerBoundary())
this.y = height - this.height*0.5;
else if(this.upperBoundary())
this.y = this.height*0.5;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.width*0.5, 0, 2*Math.PI);
ctx.stroke();
}
}
class User extends Disc {
constructor(width, height) {
super(width, height);
}
update() {
let xDiff = cursor['X'] - prevCursor['X'], yDiff = cursor['Y'] - prevCursor['Y'];
this.prevX = this.x;
this.prevY = this.y;
this.x += xDiff;
this.y += yDiff;
prevCursor['X'] = cursor['X'];
prevCursor['Y'] = cursor['Y'];
}
}
let user = new User(70, 70);
class Puck extends Disc {
constructor(width, height) {
super(width, height);
this.maxSpeed = 30;
this.speedX = 0;
this.speedY = 0;
}
reset(x, y) {
super.reset(x, y);
this.speedX = 0;
this.speedY = 0;
}
update() {
this.x += this.speedX;
this.y += this.speedY;
let speed = Math.sqrt(this.speedX*this.speedX + this.speedY*this.speedY);
let cos = Math.abs(this.speedX/speed), sin = Math.abs(this.speedY/speed);
if(speed > this.maxSpeed) {
this.speedX = Math.sign(this.speedX)*this.maxSpeed*cos;
this.speedY = Math.sign(this.speedY)*this.maxSpeed*sin;
}
}
boundaryCheck() {
if(this.leftBoundary() || this.rightBoundary())
this.speedX *= -1;
if(this.upperBoundary() || this.lowerBoundary())
this.speedY *= -1;
super.boundaryCheck();
}
}
let puck = new Puck(50, 50);
user.reset(width*0.5, 0.75*height);
puck.reset(width*0.5, 0.5*height);
function dist(x1, y1, x2, y2) { // calculates the square of the distance between two points
return (x1 - x2)*(x1 - x2) + (y1 - y2)*(y1 - y2);
}
// comparing distance between centers of disc and puck with sum of their radii to
// detect collision. Note that instead of dividing RHS with 4, I am multiplying LHS
// with 4, to avoid floating point errors
function collision(disc, puck) {
if(dist(disc.x, disc.y, puck.x, puck.y)*4 >= (disc.width + puck.width)*(disc.width + puck.width)) // no collision, return immediately
return false;
let dxDisc = disc.x - disc.prevX, dyDisc = disc.y - disc.prevY, dxPuck = puck.x - puck.prevX, dyPuck = puck.y - puck.prevY; // change in positions in this frame
// total is the value over which binary search will be performed, greater value of total
// will give greater precision
let total = 1000;
// ans is the latest time when they have not collided, which we want to find
// Initial value of ans is 0 which means just start of the frame
let low = 0, high = total, ans = 0;
while(high - low >= 1e-3) { // binary search over this time frame
let mid = (low + high)*0.5, ratio = mid/total; // a point of time in this frame
// predicted positions at this point of time
let discX = disc.prevX + dxDisc*ratio, discY = disc.prevY + dyDisc*ratio;
let puckX = puck.prevX + dxPuck*ratio, puckY = puck.prevY + dyPuck*ratio;
if(dist(discX, discY, puckX, puckY)*4 >= (disc.width + puck.width)*(disc.width + puck.width)) { // no collision till this point of time
ans = mid; // record this point of time
low = mid; // now search after this point of time
}
else // collision
high = mid; // search before this point of time
}
// finally changing the positions according to best found value of ans
let ratio = ans/total;
disc.x = disc.prevX + dxDisc*ratio;
disc.y = disc.prevY + dyDisc*ratio;
puck.x = puck.prevX + dxPuck*ratio;
puck.y = puck.prevY + dyPuck*ratio;
// changing velocity according to oblique collision of two spheres
let relX = puck.speedX - dxDisc, relY = puck.speedY - dyDisc; // relative velocity
let normal = Math.sqrt(dist(disc.x, disc.y, puck.x, puck.y)); // line of impact
let nx = (puck.x - disc.x)/normal, ny = (puck.y - disc.y)/normal; // unit vector
let rel = relX*nx + relY*ny; // dot product
puck.speedX -= rel*forceFactor*nx;
puck.speedY -= rel*forceFactor*ny;
return true;
}
window.addEventListener('load', function() {
function animate(timeStamp) {
const deltaTime = timeStamp - last;
if(deltaTime >= timeStep) {
ctx.clearRect(0, 0, width, height);
ctx.strokeRect(0, 0, width, height);
user.update();
user.boundaryCheck();
puck.update();
puck.boundaryCheck();
collision(user, puck);
user.draw();
puck.draw();
last = timeStamp - (deltaTime % timeStep);
}
requestAnimationFrame(animate);
}
animate(0);
})
stylesheet.css
body {
background-size: cover;
}
#ca {
position: absolute;
left : 37.5vw;
top: 5.5vh;
width: 22.8vw;
height: 79.1vh;
z-index: 1;
}
According to my algorithm, if a collision is detected, the puck and the disc are placed back to the position at which they are just about to collide. But sometimes the disc and puck get stuck to each other. And the other times, the puck kind of teleports to a the other side of my disc.