class Simulation {
constructor({ width, height, ctx, balls, bars }) {
this.width = width || 800;
this.height = height || 800;
this.ctx = ctx || null;
this.balls = balls || [];
this.bars = bars || [];
this.maxRadius = d3.max(balls, (a) => a.radius);
this.center = [this.width / 2, this.height / 2];
this.quadtree = null;
}
clearCanvas() {
this.ctx.fillStyle = "#ffffff";
this.ctx.clearRect(0, 0, width, height);
}
tick() {
this.clearCanvas();
this.quadtree = d3
.quadtree()
.x((d) => d.x)
.y((d) => d.y)
.extent([-1, -1], [this.width + 1, this.height + 1])
.addAll(this.balls);
this.balls.forEach((ball) => {
this.detectBallBarCollision(ball);
this.detectBallWallCollision(ball);
this.fastDetectBallCollision(ball);
ball.tick();
ball.drawCircle();
});
this.bars = this.bars.filter((bar) => bar.remove === false);
this.bars.forEach((bar) => {
this.detectBarWallCollision(bar);
this.detectBarToBarCollision(bar);
if (bar.active) {
bar.tick();
}
bar.drawRect();
});
}
fastDetectBallCollision(ball) {
const r = ball.radius + this.maxRadius;
const nx1 = ball.x - r;
const nx2 = ball.x + r;
const ny1 = ball.y - r;
const ny2 = ball.y + r;
this.quadtree.visit((visited, x1, y1, x2, y2) => {
if (visited.data && visited.data.id !== ball.id) {
// Collision
if (
geometric.lineLength([ball.pos, visited.data.pos]) <
ball.radius + visited.data.radius
) {
const keep = geometric.lineLength([
geometric.pointTranslate(ball.pos, ball.angle, ball.speed),
geometric.pointTranslate(
visited.data.pos,
visited.data.angle,
visited.data.speed
)
]);
const swap = geometric.lineLength([
geometric.pointTranslate(
ball.pos,
visited.data.angle,
visited.data.speed
),
geometric.pointTranslate(visited.data.pos, ball.angle, ball.speed)
]);
if (keep < swap) {
elasticCollision(ball, visited.data, false);
}
}
}
return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
});
}
detectBallWallCollision(ball) {
// Detect sides
const wallVertical =
ball.x <= ball.radius || ball.x >= this.width - ball.radius;
const wallHorizontal =
ball.y <= ball.radius || ball.y >= this.height - ball.radius;
if (wallVertical || wallHorizontal) {
const t0 = geometric.pointTranslate(ball.pos, ball.angle, ball.speed);
const l0 = geometric.lineLength([this.center, t0]);
const reflected = geometric.angleReflect(
ball.angle,
wallVertical ? 90 : 0
);
const t1 = geometric.pointTranslate(ball.pos, reflected, ball.speed);
const l1 = geometric.lineLength([this.center, t1]);
if (l1 < l0) {
ball.angle = reflected;
}
}
}
detectBarWallCollision(bar) {
// Determine wall collision
const wallDistance =
bar.direction === "up"
? bar.originY + bar.height
: bar.direction === "down"
? height - (bar.originY + bar.height)
: bar.direction === "left"
? bar.originX + bar.width
: this.width - (bar.originX + bar.width);
if (wallDistance <= 0) {
bar.active = false;
bar.color = lightGray;
bar.drawRect();
bar.multiplier = 0;
}
}
detectBallBarCollision(ball) {
const ballLeft = ball.x - ball.radius;
const ballRight = ball.x + ball.radius;
const ballTop = ball.y + ball.radius;
const ballBottom = ball.y - ball.radius;
this.bars.forEach((bar) => {
const verticalCollision =
((ballRight >= bar.right && ballLeft <= bar.right) ||
(ballLeft <= bar.left && ballRight >= bar.left)) &&
Math.round(ball.y) <= bar.bottom && Math.round(ball.y) >= bar.top;
const horizontalCollision =
((ballTop >= bar.top && ballBottom <= bar.top) ||
(ballBottom <= bar.bottom && ballTop >= bar.bottom)) &&
Math.round(ball.x) <= bar.right && Math.round(ball.x) >= bar.left;
if (verticalCollision || horizontalCollision) {
if (bar.active) {
bar.remove = true;
} else {
const reflected = geometric.angleReflect(
ball.angle,
verticalCollision ? 90 : 0
);
const t0 = geometric.pointTranslate(ball.pos, ball.angle, ball.speed);
const t1 = geometric.pointTranslate(ball.pos, reflected, ball.speed);
const barTargetCoordinates = verticalCollision
? [(bar.left + bar.right) / 2, ball.y]
: [ball.x, (bar.top + bar.bottom) / 2];
const l0 = geometric.lineLength([barTargetCoordinates, t0]);
const l1 = geometric.lineLength([barTargetCoordinates, t1]);
if (l1 > l0) {
ball.angle = reflected;
}
}
}
});
}
detectBarToBarCollision(bar) {
const possibleBars = this.bars.filter((candidateBar) => {
if (
candidateBar.originX === bar.originX &&
candidateBar.originY === bar.originY
) {
return false;
}
if (bar.direction === "left") {
return candidateBar.originX < bar.originX;
} else if (bar.direction === "right") {
return candidateBar.originX > bar.originX;
} else if (bar.direction === "up") {
return candidateBar.originY < bar.originY;
} else {
return candidateBar.originY > bar.originY;
}
});
possibleBars.forEach((collisionBar) => {
if (
(bar.direction === "left" &&
bar.left <= collisionBar.right &&
bar.top <= collisionBar.bottom &&
bar.bottom >= collisionBar.top) ||
(bar.direction === "right" &&
bar.right >= collisionBar.left - 1 &&
bar.top <= collisionBar.bottom &&
bar.bottom >= collisionBar.top) ||
(bar.direction === "up" &&
bar.top <= collisionBar.bottom &&
bar.left <= collisionBar.right &&
bar.right >= collisionBar.left) ||
(bar.direction === "down" &&
bar.bottom >= collisionBar.top - 1 &&
bar.left <= collisionBar.right &&
bar.right >= collisionBar.left)
) {
bar.active = false;
bar.color = lightGray;
}
});
}
}