// Copyright © Jacob DeHart 2018: http://jacobd.com/
// Inspired by Richard Poole: http://sycora.com/
// Algorithm by Craig Reynolds: http://www.red3d.com/cwr/boids/
// Licence for this file: http://creativecommons.org/licenses/MIT/
function boids() {
var canvas = document.createElement('CANVAS'),
context = canvas.getContext('2d'),
mouse = {down: false},
boidCount = 40,
boidMinSpeed = 2,
boidMaxSpeed = 4,
boidMinSize = 1,
boidMaxSize = 1,
boidMaxTurn = 10 * Math.PI / 180,
boidFOV = 270 * Math.PI / 180,
boidCosFOVDiv2 = Math.cos(boidFOV / 2),
boidDOF = 80,
boidPositionHistoryLength = 6,
separationPriority = 15,
alignmentPriority = 2,
cohesionPriority = 1,
targetPriority = 3,
mouse = {x: 0, y: 0, down: false},
debug = false,
backgroundAlpha = 0.5,
allBoids = [];
function white(alpha) {
return 'RGBA(255, 255, 255, ' + alpha + ')';
}
function text(txt, x, y) {
context.fillStyle = white(0.9);
context.font = '14px Courier';
context.fillText(txt, x, y);
}
function randBetween(a, b) {
return a + Math.random() * (b - a);
}
function Boid() {
var r = Math.floor(randBetween(50, 255)),
g = Math.floor(randBetween(50, 255)),
b = Math.floor(randBetween(50, 255));
this._color = 'RGBA(' + r + ', ' + g + ', ' + b + ', 1)';
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.positionHistory = [];
this.setDirection(6.28 * Math.random());
this.speed = randBetween(boidMinSpeed, boidMaxSpeed);
this.size = randBetween(boidMinSize, boidMaxSize);
this.id = allBoids.length;
this.first = this.id === 0;
allBoids.push(this);
}
Boid.prototype = {
x: 0,
y: 0,
vx: 0,
vy: 0,
direction: 0,
speed: 0,
size: 0,
color: function () {
return this._color;
},
drawCenter: function () {
context.fillStyle = white(0.8);
context.beginPath();
context.arc(this.x, this.y, this.first ? 5 : 1, 0, 2 * Math.PI);
context.fill();
},
drawBody: function () {
var i, x, y, offset = 2;
if (debug) {
context.strokeStyle = white(1);
context.beginPath();
context.arc(this.x, this.y, 10 + this.size, 0, 2 * Math.PI);
context.stroke();
} else {
context.save();
context.strokeStyle = 'RGBA(0, 0, 0, 0.3)';
context.lineWidth = 30;
context.lineCap = 'round';
context.beginPath();
context.moveTo(this.x, this.y + offset);
x = this.x;
y = this.y + offset;
for (i = this.positionHistory.length - 1; i >= 0; i -- ) {
x -= this.positionHistory[i][0];
y -= this.positionHistory[i][1];
context.lineTo(x, y);
}
context.stroke();
context.closePath();
context.restore();
context.save();
context.strokeStyle = this.color();
context.lineWidth = 30 - offset;
context.lineCap = 'round';
context.beginPath();
context.moveTo(this.x, this.y);
x = this.x;
y = this.y;
for (i = this.positionHistory.length - 1; i >= 0; i -- ) {
x -= this.positionHistory[i][0];
y -= this.positionHistory[i][1];
context.lineTo(x, y);
}
context.stroke();
context.closePath();
context.restore();
context.save();
context.strokeStyle = '#FFF';
context.lineWidth = offset;
context.lineCap = 'round';
context.beginPath();
context.moveTo(this.x, this.y - 10);
x = this.x;
y = this.y - 10;
for (i = this.positionHistory.length - 1; i >= 0; i -- ) {
x -= this.positionHistory[i][0];
y -= this.positionHistory[i][1];
context.lineTo(x, y);
}
context.stroke();
context.closePath();
context.restore();
}
},
drawDirection: function () {
var x = this.x + Math.cos(this.direction) * (this.size * 1),
y = this.y + Math.sin(this.direction) * (this.size * 1);
context.strokeStyle = white(0.2);
context.beginPath();
context.moveTo(this.x, this.y);
context.lineTo(x, y);
context.stroke();
context.fillStyle = white(0.8);
context.beginPath();
context.arc(x, y, 1, 0, 2 * Math.PI);
context.fill();
},
drawTarget: function () {
var x = this.x + Math.cos(this.direction) * (this.size * 1),
y = this.y + Math.sin(this.direction) * (this.size * 1);
context.strokeStyle = white(1 / boidCount);
context.beginPath();
context.moveTo(x, y);
context.lineTo(mouse.x, mouse.y);
context.stroke();
},
drawVision() {
var size = this.size + boidDOF,
leftAngle = this.direction - boidFOV / 2,
rightAngle = this.direction + boidFOV / 2;
context.strokeStyle = white(0.2);
context.beginPath();
context.moveTo(this.x, this.y);
context.arc(this.x, this.y, size, leftAngle, rightAngle);
context.lineTo(this.x, this.y);
context.stroke();
},
draw: function () {
this.drawBody();
if (debug) {
this.drawCenter();
this.drawDirection();
this.drawTarget();
this.drawVision();
}
},
move: function () {
var dx = this.vx * this.speed,
dy = this.vy * this.speed;
this.x += dx;
this.y += dy;
this.positionHistory.push([dx, dy]);
if (this.positionHistory.length > boidPositionHistoryLength) {
this.positionHistory.shift();
}
},
setDirection: function (v) {
this.direction = v;
this.vx = Math.cos(this.direction);
this.vy = Math.sin(this.direction);
},
analyzeNeighbors: function () {
var neighborData = {
x: 0,
y: 0,
vx: 0,
vy: 0,
closestDistance: 999999999,
count: 0
};
allBoids.forEach(function (boid, i) {
if (boid === this) { return; }
var dx = boid.x - this.x,
dy = boid.y - this.y,
distanceSq = dx * dx + dy * dy,
distance = Math.sqrt(distanceSq) - this.size - boid.size,
dxPercent = dx / (distance + this.size + boid.size),
dyPercent = dy / (distance + this.size + boid.size),
cosAngle = boid.vx * dxPercent + boid.vy * dyPercent;
if (distance < neighborData.closestDistance) {
neighborData.closestDistance = distance;
neighborData.closestBoid = boid;
}
if (distance <= boidDOF + this.size && cosAngle >= boidCosFOVDiv2) {
neighborData.x += boid.x;
neighborData.y += boid.y;
neighborData.vx += boid.vx;
neighborData.vy += boid.vy;
neighborData.count += 1;
}
}.bind(this));
neighborData.x /= neighborData.count;
neighborData.y /= neighborData.count;
neighborData.direction = Math.atan2(neighborData.vy, neighborData.vx);
this.neighborData = neighborData;
},
applySeparation: function () {
var dx, dy, nd = this.neighborData;
if (nd.closestBoid &&
nd.closestDistance < boidDOF + this.size - nd.closestBoid.size) {
dx = nd.closestBoid.x - this.x;
dy = nd.closestBoid.y - this.y;
this.targetAngleX -= dx / nd.closestDistance * separationPriority;
this.targetAngleY -= dy / nd.closestDistance * separationPriority;
}
},
applyAlignment: function () {
var nd = this.neighborData;
this.targetAngleX += Math.cos(nd.direction) * alignmentPriority;
this.targetAngleY += Math.sin(nd.direction) * alignmentPriority;
},
applyCohesion: function () {
var dx, dy, dxPercent, dyPercent, distance, nd = this.neighborData;
if (nd.count > 0) {
dx = nd.x - this.x;
dy = nd.y - this.y;
distance = Math.sqrt(dx * dx + dy * dy);
dxPercent = dx / distance;
dyPercent = dy / distance;
this.targetAngleX += dxPercent * cohesionPriority;
this.targetAngleY += dyPercent * cohesionPriority;
}
},
applyPrimaryTarget: function () {
var dx = mouse.x - this.x,
dy = mouse.y - this.y,
distance = Math.max(Math.sqrt(dx * dx + dy * dy), 50),
dxPercent = dx / distance,
dyPercent = dy / distance;
this.targetAngleX += dxPercent * targetPriority;
this.targetAngleY += dyPercent * targetPriority;
},
initTargetAngle: function () {
this.targetAngleX = this.vx;
this.targetAngleY = this.vy;
},
calcTargetAngle: function () {
this.targetAngle = Math.atan2(this.targetAngleY, this.targetAngleX);
},
fixTargetAngle: function () {
var deltaAngle = (this.targetAngle - this.direction + Math.PI * 4) % (Math.PI * 2),
aDeltaAngle = (this.direction - this.targetAngle + Math.PI * 4) % (Math.PI * 2),
delta = Math.abs(deltaAngle) < Math.abs(aDeltaAngle) ? deltaAngle : -aDeltaAngle;
this.targetAngle = delta;
},
applyMaxTurn: function () {
this.targetAngle = Math.min(Math.max(this.targetAngle, -boidMaxTurn), boidMaxTurn);
},
applyTargetAngle() {
this.setDirection(this.direction + this.targetAngle);
},
updateDirection: function () {
this.initTargetAngle();
this.analyzeNeighbors();
this.applySeparation();
this.applyAlignment();
this.applyCohesion()
this.applyPrimaryTarget();
this.calcTargetAngle();
this.fixTargetAngle();
this.applyMaxTurn();
this.applyTargetAngle();
},
wrap: function () {
if (this.x > canvas.width + this.size / 2) {
this.x -= canvas.width + this.size;
} else if (this.x < 0 - this.size / 2) {
this.x += canvas.width + this.size;
}
if (this.y > canvas.height + this.size / 2) {
this.y -= canvas.height + this.size;
} else if (this.y < 0 - this.size / 2) {
this.y += canvas.height + this.size;
}
},
update: function () {
this.move();
this.updateDirection();
this.wrap();
this.draw();
}
};
function updateWindow() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
function handleMove(e) {
e.preventDefault();
mouse.x = e.pageX;
mouse.y = e.pageY;
backgroundAlpha = 0.3;
}
window.onmousedown = window.ontouchstart = function () {
mouse.down = true;
debug = !debug;
};
window.onmouseup = window.ontouchend = function () {
mouse.down = false;
};
window.onresize = window.orientationchange = updateWindow;
canvas.onmousemove = canvas.ontouchmove = handleMove;
function renderLoop() {
requestAnimationFrame(renderLoop);
context.globalAlpha = 1;
if (debug) {
context.fillStyle = '#043F8C';
} else {
// context.fillStyle = 'RGBA(50, 50, 50, ' + 1 + ')';
// context.fillStyle = 'RGBA(255, 255, 255, ' + 1 + ')';
context.fillStyle = '#c70017';
}
context.fillRect(0, 0, canvas.width, canvas.height);
context.globalAlpha = 1;
backgroundAlpha += (1 - backgroundAlpha) / 100;
if (debug) {
text('Click to toggle debug', 10, 20);
context.strokeStyle = white(0.3);
context.beginPath();
context.arc(mouse.x, mouse.y, 50, 0, 2 * Math.PI);
context.stroke();
}
allBoids.forEach(function (boid) {
boid.update();
});
}
document.body.appendChild(canvas);
updateWindow();
mouse.x = canvas.width / 2;
mouse.y = canvas.height / 2;
for (var i = 0; i < boidCount; i++) {
new Boid();
}
renderLoop();
}
window.onload = boids;
body { margin: 0; padding: 0; overflow: hidden; }
console