boid
Version:
Bird-like behaviours
526 lines (468 loc) • 15.2 kB
JavaScript
import Vec2 from './vec2.js';
const PI_D2 = Math.PI / 2;
const defaults = {
bounds: {
x: 0,
y: 0,
width: 640,
height: 480
},
edgeBehavior: 'bounce',
mass: 1.0,
maxSpeed: 10,
maxForce: 1,
radius: 0,
arriveThreshold: 50,
wanderDistance: 10,
wanderRadius: 5,
wanderAngle: 0,
wanderRange: 1,
avoidDistance: 300,
avoidBuffer: 20,
pathThreshold: 20,
maxDistance: 300,
minDistance: 60
};
function getOpt(options, key) {
if (options && typeof options[key] !== 'undefined') {
return options[key];
}
return defaults[key];
}
export default function Boid(options) {
let boid = null;
const position = Vec2.get();
const velocity = Vec2.get();
const steeringForce = Vec2.get();
const bounds = Object.assign({}, defaults.bounds, (options && options.bounds));
let edgeBehavior = getOpt(options, 'edgeBehavior');
let mass = getOpt(options, 'mass');
let maxSpeed = getOpt(options, 'maxSpeed');
let maxSpeedSq = maxSpeed * maxSpeed;
let maxForce = getOpt(options, 'maxForce');
let radius = getOpt(options, 'radius');
// arrive
let arriveThreshold = getOpt(options, 'arriveThreshold');
let arriveThresholdSq = arriveThreshold * arriveThreshold;
// wander
let wanderDistance = getOpt(options, 'wanderDistance');
let wanderRadius = getOpt(options, 'wanderRadius');
let wanderAngle = getOpt(options, 'wanderAngle');
let wanderRange = getOpt(options, 'wanderRange');
// avoid
let avoidDistance = getOpt(options, 'avoidDistance');
let avoidBuffer = getOpt(options, 'avoidBuffer');
// follow path
let pathIndex = 0;
let pathThreshold = getOpt(options, 'pathThreshold');
let pathThresholdSq = pathThreshold * pathThreshold;
// flock
let maxDistance = getOpt(options, 'maxDistance');
let maxDistanceSq = maxDistance * maxDistance;
let minDistance = getOpt(options, 'minDistance');
let minDistanceSq = minDistance * minDistance;
function setBounds(width, height, x, y) {
bounds.width = width;
bounds.height = height;
bounds.x = x || 0;
bounds.y = y || 0;
return boid;
}
function bounce() {
const minX = bounds.x + radius;
const maxX = bounds.x + bounds.width - radius;
if (position.x > maxX) {
position.x = maxX;
velocity.x *= -1;
} else if (position.x < minX) {
position.x = minX;
velocity.x *= -1;
}
const minY = bounds.y + radius;
const maxY = bounds.y + bounds.height - radius;
if (position.y > maxY) {
position.y = maxY;
velocity.y *= -1;
} else if (position.y < minY) {
position.y = minY;
velocity.y *= -1;
}
}
function wrap() {
const minX = bounds.x - radius;
const maxX = bounds.x + bounds.width + radius;
if (position.x > maxX) {
position.x = minX;
} else if (position.x < minX) {
position.x = maxX;
}
const minY = bounds.y - radius;
const maxY = bounds.y + bounds.height + radius;
if (position.y > maxY) {
position.y = minY;
} else if (position.y < minY) {
position.y = maxY;
}
}
function seek(targetVec) {
const desiredVelocity = targetVec.clone().subtract(position);
desiredVelocity.normalize();
desiredVelocity.scaleBy(maxSpeed);
const force = desiredVelocity.subtract(velocity);
steeringForce.add(force);
force.dispose();
return boid;
}
function flee(targetVec) {
const desiredVelocity = targetVec.clone().subtract(position);
desiredVelocity.normalize();
desiredVelocity.scaleBy(maxSpeed);
const force = desiredVelocity.subtract(velocity);
steeringForce.subtract(force);
force.dispose();
return boid;
}
// seek until within arriveThreshold
function arrive(targetVec) {
const desiredVelocity = targetVec.clone().subtract(position);
desiredVelocity.normalize();
const distanceSq = position.distanceSq(targetVec);
if (distanceSq > arriveThresholdSq) {
desiredVelocity.scaleBy(maxSpeed);
} else {
const scalar = maxSpeed * distanceSq / arriveThresholdSq;
desiredVelocity.scaleBy(scalar);
}
const force = desiredVelocity.subtract(velocity);
steeringForce.add(force);
force.dispose();
return boid;
}
// look at velocity of boid and try to predict where it's going
function pursue(targetBoid) {
const lookAheadTime = position.distanceSq(targetBoid.position) / maxSpeedSq;
const scaledVelocity = targetBoid.velocity.clone().scaleBy(lookAheadTime);
const predictedTarget = targetBoid.position.clone().add(scaledVelocity);
seek(predictedTarget);
scaledVelocity.dispose();
predictedTarget.dispose();
return boid;
}
// look at velocity of boid and try to predict where it's going
function evade(targetBoid) {
const lookAheadTime = position.distanceSq(targetBoid.position) / maxSpeedSq;
const scaledVelocity = targetBoid.velocity.clone().scaleBy(lookAheadTime);
const predictedTarget = targetBoid.position.clone().add(scaledVelocity);
flee(predictedTarget);
scaledVelocity.dispose();
predictedTarget.dispose();
return boid;
}
// wander around, changing angle by a limited amount each tick
function wander() {
const center = velocity.clone().normalize().scaleBy(wanderDistance);
const offset = Vec2.get();
offset.set(wanderAngle, wanderRadius);
// offset.length = wanderRadius;
// offset.angle = wanderAngle;
wanderAngle += Math.random() * wanderRange - wanderRange * 0.5;
const force = center.add(offset);
steeringForce.add(force);
offset.dispose();
force.dispose();
return boid;
}
// gets a bit rough used in combination with seeking as the boid attempts
// to seek straight through an object while simultaneously trying to avoid it
function avoid(obstacles) {
for (let i = 0; i < obstacles.length; i++) {
const obstacle = obstacles[i];
const heading = velocity.clone().normalize();
// vec between obstacle and boid
const difference = obstacle.position.clone().subtract(position);
const dotProd = difference.dotProduct(heading);
// if obstacle in front of boid
if (dotProd > 0) {
// vec to represent 'feeler' arm
const feeler = heading.clone().scaleBy(avoidDistance);
// project difference onto feeler
const projection = heading.clone().scaleBy(dotProd);
// distance from obstacle to feeler
const vecDistance = projection.subtract(difference);
const distance = vecDistance.length;
// if feeler intersects obstacle (plus buffer), and projection
// less than feeler length, will collide
if (distance < (obstacle.radius || 0) + avoidBuffer && projection.length < feeler.length) {
// calc a force +/- 90 deg from vec to circ
const force = heading.clone().scaleBy(maxSpeed);
force.angle += difference.sign(velocity) * PI_D2;
// scale force by distance (further = smaller force)
const dist = projection.length / feeler.length;
force.scaleBy(1 - dist);
// add to steering force
steeringForce.add(force);
// braking force - slows boid down so it has time to turn (closer = harder)
velocity.scaleBy(dist);
force.dispose();
}
feeler.dispose();
projection.dispose();
vecDistance.dispose();
}
heading.dispose();
difference.dispose();
}
return boid;
}
// follow a path made up of an array or vectors
function followPath(path, loop) {
loop = !!loop;
const wayPoint = path[pathIndex];
if (!wayPoint) {
pathIndex = 0;
return boid;
}
if (position.distanceSq(wayPoint) < pathThresholdSq) {
if (pathIndex >= path.length - 1) {
if (loop) {
pathIndex = 0;
}
} else {
pathIndex++;
}
}
if (pathIndex >= path.length - 1 && !loop) {
arrive(wayPoint);
} else {
seek(wayPoint);
}
return boid;
}
// is boid close enough to be in sight and facing
function inSight(b) {
if (position.distanceSq(b.position) > maxDistanceSq) {
return false;
}
const heading = velocity.clone().normalize();
const difference = b.position.clone().subtract(position);
const dotProd = difference.dotProduct(heading);
heading.dispose();
difference.dispose();
return dotProd >= 0;
}
// flock - group of boids loosely move together
function flock(boids) {
const averageVelocity = velocity.clone();
const averagePosition = Vec2.get();
let inSightCount = 0;
for (let i = 0; i < boids.length; i++) {
const b = boids[i];
if (b !== boid && inSight(b)) {
averageVelocity.add(b.velocity);
averagePosition.add(b.position);
if (position.distanceSq(b.position) < minDistanceSq) {
flee(b.position);
}
inSightCount++;
}
}
if (inSightCount > 0) {
averageVelocity.divideBy(inSightCount);
averagePosition.divideBy(inSightCount);
seek(averagePosition);
steeringForce.add(averageVelocity.subtract(velocity));
}
averageVelocity.dispose();
averagePosition.dispose();
return boid;
}
function update() {
steeringForce.truncate(maxForce);
if (mass !== 1) {
steeringForce.divideBy(mass);
}
// velocity.add(steeringForce);
velocity.x += steeringForce.x;
velocity.y += steeringForce.y;
// steeringForce.reset();
steeringForce.x = 0;
steeringForce.y = 0;
velocity.truncate(maxSpeed);
// position.add(velocity);
position.x += velocity.x;
position.y += velocity.y;
if (edgeBehavior === Boid.EDGE_BOUNCE) {
bounce();
} else if (edgeBehavior === Boid.EDGE_WRAP) {
wrap();
}
return boid;
}
boid = {
bounds,
setBounds,
update,
pursue,
evade,
wander,
avoid,
followPath,
flock,
arrive,
seek,
flee,
position,
velocity,
userData: {}
};
// getters / setters
Object.defineProperties(boid, {
edgeBehavior: {
get: function() {
return edgeBehavior;
},
set: function(value) {
edgeBehavior = value;
}
},
mass: {
get: function() {
return mass;
},
set: function(value) {
mass = value;
}
},
maxSpeed: {
get: function() {
return maxSpeed;
},
set: function(value) {
maxSpeed = value;
maxSpeedSq = value * value;
}
},
maxForce: {
get: function() {
return maxForce;
},
set: function(value) {
maxForce = value;
}
},
radius: {
get: function() {
return radius;
},
set: function(value) {
radius = value;
}
},
// arrive
arriveThreshold: {
get: function() {
return arriveThreshold;
},
set: function(value) {
arriveThreshold = value;
arriveThresholdSq = value * value;
}
},
// wander
wanderDistance: {
get: function() {
return wanderDistance;
},
set: function(value) {
wanderDistance = value;
}
},
wanderRadius: {
get: function() {
return wanderRadius;
},
set: function(value) {
wanderRadius = value;
}
},
wanderRange: {
get: function() {
return wanderRange;
},
set: function(value) {
wanderRange = value;
}
},
// avoid
avoidDistance: {
get: function() {
return avoidDistance;
},
set: function(value) {
avoidDistance = value;
}
},
avoidBuffer: {
get: function() {
return avoidBuffer;
},
set: function(value) {
avoidBuffer = value;
}
},
// followPath
pathIndex: {
get: function() {
return pathIndex;
},
set: function(value) {
pathIndex = value;
}
},
pathThreshold: {
get: function() {
return pathThreshold;
},
set: function(value) {
pathThreshold = value;
pathThresholdSq = value * value;
}
},
// flock
maxDistance: {
get: function() {
return maxDistance;
},
set: function(value) {
maxDistance = value;
maxDistanceSq = value * value;
}
},
minDistance: {
get: function() {
return minDistance;
},
set: function(value) {
minDistance = value;
minDistanceSq = value * value;
}
}
});
return Object.freeze(boid);
}
// edge behaviors
Boid.EDGE_NONE = 'none';
Boid.EDGE_BOUNCE = 'bounce';
Boid.EDGE_WRAP = 'wrap';
// vec2
Boid.Vec2 = Vec2;
Boid.vec2 = function(x, y) {
return Vec2.get(x, y);
};
// for defining obstacles or areas to avoid
Boid.obstacle = function(radius, x, y) {
return {
radius: radius,
position: Vec2.get(x, y)
};
};