-
-
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.