-
-
Save joshuabradley012/bd2bc96bbe1909ca8555a792d6a36e04 to your computer and use it in GitHub Desktop.
class State { | |
constructor(display, actors) { | |
this.display = display; | |
this.actors = actors; | |
} | |
update(time) { | |
/** | |
* provide an update ID to let actors update other actors only once | |
* used with collision detection | |
*/ | |
const updateId = Math.floor(Math.random() * 1000000); | |
const actors = this.actors.map(actor => { | |
return actor.update(this, time, updateId); | |
}); | |
return new State(this.display, actors); | |
} | |
} | |
class Vector { | |
constructor(x, y) { | |
this.x = x; | |
this.y = y; | |
} | |
add(vector) { | |
return new Vector(this.x + vector.x, this.y + vector.y); | |
} | |
subtract(vector) { | |
return new Vector(this.x - vector.x, this.y - vector.y); | |
} | |
multiply(scalar) { | |
return new Vector(this.x * scalar, this.y * scalar); | |
} | |
dotProduct(vector) { | |
return this.x * vector.x + this.y * vector.y; | |
} | |
get magnitude() { | |
return Math.sqrt(this.x ** 2 + this.y ** 2); | |
} | |
get direction() { | |
return Math.atan2(this.x, this.y); | |
} | |
} | |
class Canvas { | |
constructor(parent = document.body, width = 400, height = 400) { | |
this.canvas = document.createElement('canvas'); | |
this.canvas.width = width; | |
this.canvas.height = height; | |
parent.appendChild(this.canvas); | |
this.ctx = this.canvas.getContext('2d'); | |
} | |
sync(state) { | |
this.clearDisplay(); | |
this.drawActors(state.actors); | |
} | |
clearDisplay() { | |
// opacity controls the trail effect set to 1 to remove | |
this.ctx.fillStyle = 'rgba(255, 255, 255, .4)'; | |
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); | |
this.ctx.strokeStyle = 'black'; | |
this.ctx.strokeRect(0, 0, this.canvas.width, this.canvas.height); | |
} | |
drawActors(actors) { | |
for (let actor of actors) { | |
if (actor.type === 'circle') { | |
this.drawCircle(actor); | |
} | |
} | |
} | |
drawCircle(actor) { | |
this.ctx.beginPath(); | |
this.ctx.arc(actor.position.x, actor.position.y, actor.radius, 0, Math.PI * 2); | |
this.ctx.closePath(); | |
this.ctx.fillStyle = actor.color; | |
this.ctx.fill(); | |
} | |
} | |
class Ball { | |
constructor(config) { | |
Object.assign(this, | |
{ | |
id: Math.floor(Math.random() * 1000000), | |
type: 'circle', | |
position: new Vector(100, 100), | |
velocity: new Vector(5, 3), | |
radius: 25, | |
color: 'blue', | |
collisions: [], | |
}, | |
config | |
); | |
} | |
update(state, time, updateId) { | |
/** | |
* if slice occurs on too many elements, it starts to lag | |
* collisions is an array to allow multiple collisions at once | |
*/ | |
if (this.collisions.length > 10) { | |
this.collisions = this.collisions.slice(this.collisions.length - 3); | |
} | |
/** | |
* this is the most stable solution to avoid overlap | |
* but it is slightly inaccurate | |
*/ | |
for (let actor of state.actors) { | |
if (this === actor || this.collisions.includes(actor.id + updateId)) { | |
continue; | |
} | |
/** | |
* check if actors collide in the next frame and update now if they do | |
* innaccurate, but it is the easiest solution to the sticky collision bug | |
*/ | |
const distance = this.position.add(this.velocity).subtract(actor.position.add(actor.velocity)).magnitude; | |
if (distance <= this.radius + actor.radius) { | |
const v1 = collisionVector(this, actor); | |
const v2 = collisionVector(actor, this); | |
this.velocity = v1; | |
actor.velocity = v2; | |
this.collisions.push(actor.id + updateId); | |
actor.collisions.push(this.id + updateId); | |
} | |
} | |
// setting bounds on the canvas prevents balls from overlapping on update | |
const upperLimit = new Vector(state.display.canvas.width - this.radius, state.display.canvas.height - this.radius); | |
const lowerLimit = new Vector(0 + this.radius, 0 + this.radius); | |
// check if hitting left or right of container | |
if (this.position.x >= upperLimit.x || this.position.x <= lowerLimit.x) { | |
this.velocity = new Vector(-this.velocity.x, this.velocity.y); | |
} | |
// check if hitting top or bottom of container | |
if (this.position.y >= upperLimit.y || this.position.y <= lowerLimit.y) { | |
this.velocity = new Vector(this.velocity.x, -this.velocity.y); | |
} | |
const newX = Math.max(Math.min(this.position.x + this.velocity.x, upperLimit.x), lowerLimit.x); | |
const newY = Math.max(Math.min(this.position.y + this.velocity.y, upperLimit.y), lowerLimit.y); | |
return new Ball({ | |
...this, | |
position: new Vector(newX, newY), | |
}); | |
} | |
get area() { | |
return Math.PI * this.radius ** 2; | |
} | |
get sphereArea() { | |
return 4 * Math.PI * this.radius ** 2; | |
} | |
} | |
// see elastic collision: https://en.wikipedia.org/wiki/Elastic_collision | |
const collisionVector = (particle1, particle2) => { | |
return particle1.velocity | |
.subtract(particle1.position | |
.subtract(particle2.position) | |
.multiply(particle1.velocity | |
.subtract(particle2.velocity) | |
.dotProduct(particle1.position.subtract(particle2.position)) | |
/ particle1.position.subtract(particle2.position).magnitude ** 2 | |
) | |
// add mass to the system | |
.multiply((2 * particle2.sphereArea) / (particle1.sphereArea + particle2.sphereArea)) | |
); | |
}; | |
const isMovingTowards = (particle1, particle2) => { | |
return particle2.position.subtract(particle1.position).dotProduct(particle1.velocity) > 0; | |
}; | |
const runAnimation = animation => { | |
let lastTime = null; | |
const frame = time => { | |
if (lastTime !== null) { | |
const timeStep = Math.min(100, time - lastTime) / 1000; | |
// return false from animation to stop | |
if (animation(timeStep) === false) { | |
return; | |
} | |
} | |
lastTime = time; | |
requestAnimationFrame(frame); | |
}; | |
requestAnimationFrame(frame); | |
}; | |
const random = (max = 9, min = 0) => { | |
return Math.floor(Math.random() * (max - min + 1) + min); | |
}; | |
const colors = ['red', 'green', 'blue', 'purple', 'orange']; | |
const collidingBalls = ({ width = 400, height = 400, parent = document.body, count = 50 } = {}) => { | |
const display = new Canvas(parent, width, height); | |
const balls = []; | |
for (let i = 0; i < count; i++) { | |
balls.push(new Ball({ | |
radius: random(8, 3) + Math.random(), | |
color: colors[random(colors.length - 1)], | |
position: new Vector(random(width - 10, 10), random(height - 10, 10)), | |
velocity: new Vector(random(3, -3), random(3, -3)), | |
})); | |
} | |
let state = new State(display, balls); | |
runAnimation(time => { | |
state = state.update(time); | |
display.sync(state); | |
}); | |
}; | |
collidingBalls(); |
I found your site when searching for code that I could modify to simulate gas molecules impacting a rough surface for a research project. It's brilliant, but....
After a few minutes running the average speed of the balls starts to increase, and fairly soon goes ballistic. This is a bit of a show-stopper for me as I will need it to run in a stable way for tens of minutes. I assume the issue derives from rounding errors, which will be pretty hard to trace, but I wonder if you have addressed this issue and maybe have a remedy?
PS, I'm running on a Mac in Safari and Chrome.
@StarTraX I've noticed the same issue. I also believe it derives from rounding errors, but I've noticed it runs differently on different OSs as well, so there may be a frame timing issue.
I have not addressed this issue, unfortunately.
@StarTraX and @joshuabradley012, it seems that whenever the mass multiplier evaluates to anything greater than 1, it adds entropy and speed into the system. So, putting the multiplier under 1 reduces both the randomness and acceleration.
@cyrilf got it! Thank you. Updating now.