Swarm behaviour!

This commit is contained in:
2024-05-04 14:17:06 +02:00
commit 029034215d
32 changed files with 15063 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
import { Canvas } from "@/canvas";
import { GameObject } from "@/common/gameobject";
import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec";
import { Particle } from "@/game/fluids/particle";
import { strokeCircle } from "@/graphics";
const gravity = 0.008;
const numParticles = 200;
const pressureForce = 8000;
export const particleSize = 10;
export const desiredDensity = 6;
const densityRadius = 40;
const mass = 1;
const viscosityStrength = 0.4;
const viscosityRadius = 100;
const mouseRadius = 200;
const mouseForce = 0.2;
const densityKernelVolume = Math.PI * Math.pow(densityRadius, 4) / 6;
const derivativeScale = 12 / (Math.PI * Math.pow(densityRadius, 4));
const viscosityKernelVolume = Math.PI * Math.pow(viscosityRadius, 8) / 4;
export class Fluids extends GameObject {
particles: Particle[];
rect: Rect;
constructor(canvas: Canvas) {
super();
this.particles = [];
this.rect = new Rect(0, 0, canvas.width, canvas.height);
this.init();
// @ts-ignore
window.fluids = this;
}
init() {
const rectInTheMiddle = this.rect.translate(this.rect.tl.scale(0.25)).scale(0.5);
const cols = Math.floor(Math.sqrt(numParticles));
const rows = Math.floor(numParticles / cols);
const spacing = rectInTheMiddle.width / cols;
for (let x = 0; x < cols; x++) {
for (let y = 0; y < rows; y++) {
const pos = rectInTheMiddle.tl.add(x * spacing, y * spacing);
this.particles.push(new Particle(pos));
}
}
}
update(canvas: Canvas, delta: DOMHighResTimeStamp) {
// fluid simulation seems to be breaking when browser goes to sleep
delta = Math.min(delta, 10);
this.predictParticles(delta);
this.applyGravityForce(delta);
this.applyPressureForce(delta);
this.applyViscosityForce(delta);
this.applyMouseForce(canvas);
this.particles.forEach(p => p.update(canvas, delta));
}
applyMouseForce({input}: Canvas) {
if (input.mouseDown) {
for (const p of this.particles) {
const diff = input.mousePos.sub(p.pos);
const dist = diff.length();
if (dist < mouseRadius) {
p.vel = p.vel.add(diff.normalize().scale(mouseForce));
}
}
}
}
predictParticles(delta: DOMHighResTimeStamp) {
for (const p of this.particles) {
p.posPrediction = p.pos.add(p.vel.scale(delta));
}
}
smoothingKernel(dist: number) {
const diff = densityRadius - dist;
return diff * diff / densityKernelVolume;
}
smoothingKernelDerivative(dist: number) {
if (dist >= densityRadius) return 0;
return (dist - densityRadius) * derivativeScale;
}
calculateDensities() {
for (const p of this.particles) {
p.densitySample = 0;
for (const other of this.particles) {
if (p === other) continue;
const dist = p.posPrediction.distance(other.posPrediction);
p.densitySample += mass * this.smoothingKernel(dist);
}
}
}
convertDensityToPressure(density: number) {
const densityError = density - desiredDensity;
return densityError * pressureForce;
}
calculateSharedPressure(a: Particle, b: Particle) {
const pressureA = this.convertDensityToPressure(a.densitySample);
const pressureB = this.convertDensityToPressure(b.densitySample);
return (pressureA + pressureB) / 2;
}
calculatePressureForce(p: Particle) {
let pressureForce = new Vec;
for (const other of this.particles) {
if (p === other) continue;
const diff = p.posPrediction.sub(other.posPrediction);
const dist = diff.length();
const dir = dist <= 0 ? Vec.randomUnit() : diff.normalize();
const slope = this.smoothingKernelDerivative(dist);
const density = other.densitySample;
const sharedPressure = this.calculateSharedPressure(p, other);
pressureForce = pressureForce.sub(
dir.scale(
sharedPressure * slope * mass / density
)
);
}
return pressureForce;
}
viscositySmoothingKernel(dist: number) {
const value = Math.max(0, viscosityRadius * viscosityRadius - dist * dist);
return value * value * value / viscosityKernelVolume;
}
calculateViscosityForce(p: Particle) {
let viscosityForce = new Vec;
for (const other of this.particles) {
if (p === other) continue;
const dist = p.posPrediction.distance(other.posPrediction);
const influence = this.viscositySmoothingKernel(dist);
viscosityForce = viscosityForce.add(
other.vel.sub(p.vel).scale(influence)
);
}
return viscosityForce.scale(viscosityStrength);
}
applyPressureForce(delta: DOMHighResTimeStamp) {
this.calculateDensities();
for (const p of this.particles) {
p.vel = p.vel.add(this.calculatePressureForce(p).scale(delta));
}
}
applyViscosityForce(delta: DOMHighResTimeStamp) {
for (const p of this.particles) {
p.vel = p.vel.add(this.calculateViscosityForce(p).scale(delta));
}
}
applyGravityForce(delta: DOMHighResTimeStamp) {
for (const p of this.particles) {
p.vel = p.vel.add(new Vec(0, gravity * delta));
}
}
render(canvas: Canvas) {
this.particles.forEach(p => p.render(canvas));
if (canvas.input.mouseDown) {
strokeCircle(canvas.ctx, canvas.input.mousePos, mouseRadius, "white");
}
}
getAverageDensity() {
return this.particles.reduce((acc, p) => acc + p.densitySample, 0) / this.particles.length;
}
getLowestDensity() {
return this.particles.reduce((acc, p) => Math.min(acc, p.densitySample), Infinity);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Canvas } from "@/canvas";
import { clamp } from "@/common/functions";
import { Vec } from "@/common/vec";
import { desiredDensity, particleSize } from "@/game/fluids/fluids";
import { fillCircle } from "@/graphics";
const padding = 50;
export class Particle {
vel: Vec;
pos: Vec;
posPrediction: Vec;
densitySample: number;
constructor(pos: Vec) {
this.pos = pos;
this.posPrediction = new Vec;
this.vel = new Vec;
this.densitySample = 0;
}
update(canvas: Canvas, delta: DOMHighResTimeStamp) {
const nextPos = this.pos.add(this.vel);
if (nextPos.y >= canvas.height - padding || nextPos.y < padding) {
this.vel.y *= -1;
nextPos.y = clamp(nextPos.y, padding, canvas.height - padding);
}
if (nextPos.x >= canvas.width - padding || nextPos.x < padding) {
this.vel.x *= -1;
nextPos.x = clamp(nextPos.x, padding, canvas.width - padding);
}
this.pos = nextPos;
}
render({ctx}: Canvas) {
const densityError = this.densitySample - desiredDensity; // [0, inf)
const densityErrorNormalized = Math.min(densityError * densityError / 4, 255); // [0, 255]
const inverted = 255 - densityErrorNormalized;
fillCircle(ctx, this.pos, particleSize, `rgb(${densityErrorNormalized}, 0, ${inverted})`);
}
}
+168
View File
@@ -0,0 +1,168 @@
import { Canvas } from "@/canvas";
import { create2DArray } from "@/common/array";
import { GameObject } from "@/common/gameobject";
import { GridSize } from "@/common/grid";
import { perlinNoise } from "@/common/noise";
import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec";
import { Black, Blue, Color, Green, White } from "@/ui/colors";
export type TileState = 'land'|'air'|'none';
const TileColors: Record<TileState, Color> = {
land: White,
air: Blue,
none: Black,
};
export type Tile = {
state: TileState,
}
export class Gamemap extends GameObject {
public tiles: Tile[][];
public cols: number;
public rows: number;
public rect: Rect;
constructor(canvas: Canvas) {
super();
this.rows = Math.floor(canvas.width / GridSize);
this.cols = Math.floor(canvas.height / GridSize);
this.rect = new Rect(0, 0, this.rows, this.cols);
this.tiles = create2DArray(this.rows, this.cols, (x: number, y: number) => ({state: 'air'}));
}
getState(x: number, y: number): TileState;
getState(pos: Vec): TileState;
getState(x: number|Vec, y?: number): TileState {
const pos = x instanceof Vec ? x : new Vec(x, y);
return this.tiles[pos.x][pos.y].state;
}
setState(x: number, y: number, state: TileState): void;
setState(pos: Vec, state: TileState): void;
setState(x: number|Vec, y: TileState|number, state?: TileState) {
if (x instanceof Vec && "string" === typeof y) {
this.tiles[x.x][x.y].state = y;
} else if ("number" === typeof x && "number" === typeof y && "string" === typeof state) {
this.tiles[x][y].state = state;
} else {
throw new TypeError("Invalid arguments for setState");
}
}
render(canvas: Canvas): void {
const {ctx} = canvas;
for (let x = 0; x < this.rows; x++) {
for (let y = 0; y < this.cols; y++) {
ctx.fillStyle = TileColors[this.getState(x, y)];
ctx.fillRect(x * GridSize, y * GridSize, GridSize, GridSize);
}
}
}
update(canvas: Canvas) {
/** noop */
}
}
function findNearestTile(map: Gamemap, pos: Vec): Vec|null {
let distance = 1;
while (distance < Math.max(map.rows, map.cols)) {
for (let i = -distance; i <= distance; i++) {
const top = pos.add(i, -distance);
if (map.rect.contains(top) && map.getState(top) === 'land') {
return top;
}
const bottom = pos.add(i, distance);
if (map.rect.contains(bottom) && map.getState(bottom) === 'land') {
return bottom;
}
const left = pos.add(-distance, i);
if (map.rect.contains(left) && map.getState(left) === 'land') {
return left;
}
const right = pos.add(distance, i);
if (map.rect.contains(right) && map.getState(right) === 'land') {
return right;
}
}
distance++;
}
return null;
}
function marchToFirstEmptyTile(map: Gamemap, from: Vec, to: Vec): Vec|null {
const direction = to.sub(from).normalize();
if (from.distance(to) <= 1) {
return null;
}
if (Math.abs(direction.x) > Math.abs(direction.y)) {
direction.x = Math.sign(direction.x);
direction.y = 0;
} else {
direction.x = 0;
direction.y = Math.sign(direction.y);
}
const pos = from.add(direction);
if (map.getState(pos) !== 'land') {
return pos;
}
return marchToFirstEmptyTile(map, pos, to);
}
function floodFill(map: Gamemap, start: Vec, state: TileState): void {
const stack: Vec[] = [start];
const removeState = map.getState(start);
while (stack.length) {
const pos = stack.pop();
if (!pos || !map.rect.contains(pos) || map.getState(pos) !== removeState) {
continue;
}
map.setState(pos, state);
stack.push(
pos.add(1, 0),
pos.add(-1, 0),
pos.add(0, 1),
pos.add(0, -1),
);
}
}
function removeAirPockets(map: Gamemap): void {
for (let x = 0; x < map.rows; x++) {
for (let y = 0; y < map.cols; y++) {
if (map.getState(x, y) === 'air') {
map.setState(x, y, 'land');
}
}
}
}
export function fillMap(map: Gamemap): void {
for (let x = 0; x < map.rows; x++) {
for (let y = 0; y < map.cols; y++) {
const noise =
perlinNoise(new Vec(x, y).scale(0.03));
if (noise > 0.5) {
map.setState(x, y, 'land');
}
}
}
for (let i = 0; i < map.rows * map.cols * 0.3; i++) {
const pos = Vec.random(map.rows - 1, map.cols - 1);
const tile = findNearestTile(map, pos);
if (!tile) {
throw new Error("No tile found, wtf?!");
}
const emptyTile = marchToFirstEmptyTile(map, tile, pos) ?? pos;
map.setState(emptyTile, 'land');
}
floodFill(map, new Vec(0, 0), 'none');
removeAirPockets(map);
}
+195
View File
@@ -0,0 +1,195 @@
import { Canvas } from "@/canvas";
import { clamp } from "@/common/functions";
import { Vec } from "@/common/vec";
import { desiredDensity, particleSize } from "@/game/fluids/fluids";
import { Swarm } from "@/game/swarm/swarm";
import { fillCircle, strokeLine } from "@/graphics";
const padding = 50;
const allowedSteer = 2 / 180;
const idlingUrgency = 0.001;
const idlingDistance = 20;
const desiredDistanceToEveryone = 30;
const flockPerceptionDistance = 150;
const behaveLikeFlockDoesUrgency = 0.8;
const epsilon = 1e-5;
export type NavigationType = 'stayInBounds' | 'doNotBumpIntoFlock' | 'keepFlockClose' | 'behaveLikeFlockDoes' | 'idling';
export type NavigatingDesire = {
pos: Vec;
urgency: number; // [0,1]
type: NavigationType;
}
const navigationColorMap = {
stayInBounds: 'red',
doNotBumpIntoFlock: 'blue',
keepFlockClose: 'green',
behaveLikeFlockDoes: 'yellow',
idling: 'purple'
};
export class Agent {
dir: Vec;
pos: Vec;
swarm: Swarm;
idlingGoal: Vec|null = null;
lastNavigation: NavigatingDesire;
constructor(pos: Vec, swarm: Swarm) {
this.pos = pos;
this.swarm = swarm;
this.lastNavigation = {
pos: pos,
urgency: 0,
type: 'idling'
}
this.dir = Vec.randomUnit();
}
update(canvas: Canvas, delta: DOMHighResTimeStamp) {
const nextPos = this.pos.add(this.dir);
const navigations = [];
/**if (nextPos.y >= canvas.height - padding || nextPos.y < padding) {
this.vel.y *= -1;
nextPos.y = clamp(nextPos.y, padding, canvas.height - padding);
}
if (nextPos.x >= canvas.width - padding || nextPos.x < padding) {
this.vel.x *= -1;
nextPos.x = clamp(nextPos.x, padding, canvas.width - padding);
}*/
navigations.push(this.stayInBounds(nextPos));
navigations.push(this.doNotBumpIntoFlock(nextPos));
navigations.push(this.keepFlockClose(nextPos));
navigations.push(this.behaveLikeFlockDoes(nextPos));
navigations.push(this.idling(nextPos));
const navigation = this.mostUrgentNavigation(navigations);
this.steerTowards(navigation.pos);
this.lastNavigation = navigation;
this.pos = nextPos;
}
steerTowards(target: Vec) {
const targetDir = target.sub(this.pos);
// no division by zero!
if (targetDir.length() < epsilon) {
return;
}
const alpha = this.dir.clockwiseAngleBetween(targetDir.normalize());
this.dir = this.dir.rotate(Math.min(allowedSteer, Math.abs(alpha)) * Math.sign(alpha)).normalize();
}
mostUrgentNavigation(navigations: NavigatingDesire[]) {
return navigations
.reduce((a, b) => a.urgency > b.urgency ? a : b);
}
stayInBounds(nextPos: Vec): NavigatingDesire {
const {rect} = this.swarm;
if (rect.contains(this.pos.add(this.dir.scale(padding)))) {
return {
pos: nextPos,
urgency: 0,
type: 'stayInBounds'
};
}
return {
pos: rect.center,
urgency: 1,
type: 'stayInBounds'
};
}
doNotBumpIntoFlock(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm;
const visibleAgentsNotMe = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance && a !== this);
const visibleAgentsInBounds = visibleAgentsNotMe
.filter(a => this.swarm.rect.contains(a.pos));
let closestAgent: Agent|null = null;
for (const a of visibleAgentsInBounds) {
if (!closestAgent || a.pos.distance(this.pos) < closestAgent.pos.distance(this.pos)) {
closestAgent = a;
}
}
if (!closestAgent) {
return {
pos: nextPos,
urgency: 0,
type: 'doNotBumpIntoFlock'
};
}
const awayFromClosestAgentDir = this.pos.sub(closestAgent.pos).normalize();
return {
pos: this.pos.add(awayFromClosestAgentDir),
urgency: (desiredDistanceToEveryone - closestAgent.pos.distance(this.pos)) / desiredDistanceToEveryone,
type: 'doNotBumpIntoFlock'
};
}
keepFlockClose(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm;
const visibleAgents = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance);
const visibleAgentsInBounds = visibleAgents
.filter(a => this.swarm.rect.contains(a.pos));
const center = visibleAgentsInBounds
.reduce((sum, a) => sum.add(a.pos), new Vec)
.scale(1 / visibleAgentsInBounds.length);
const distanceToCenter = this.pos.distance(center);
return {
pos: center,
urgency: distanceToCenter / flockPerceptionDistance,
type: 'keepFlockClose'
};
}
behaveLikeFlockDoes(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm;
const visibleAgents = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance);
const visibleAgentsInBounds = visibleAgents
.filter(a => this.swarm.rect.contains(a.pos));
const averageDir = visibleAgentsInBounds
.reduce((sum, a) => sum.add(a.dir), new Vec)
.scale(1 / visibleAgentsInBounds.length);
const angleDiff = this.dir.clockwiseAngleBetween(averageDir);
return {
pos: nextPos.add(averageDir),
urgency: behaveLikeFlockDoesUrgency * Math.abs(angleDiff) / Math.PI,
type: 'behaveLikeFlockDoes'
};
}
idling(nextPos: Vec): NavigatingDesire {
if (this.idlingGoal && this.idlingGoal.distance(nextPos) > idlingDistance) {
return {
pos: this.idlingGoal,
urgency: idlingUrgency,
type: 'idling'
};
}
this.idlingGoal = Vec.random(this.swarm.rect);
return {
pos: this.idlingGoal,
urgency: idlingUrgency,
type: 'idling'
}
}
/**
* @returns {Number} between 0 and 255
*/
satisfaction(): number {
return 255 - this.lastNavigation.urgency * 255;
}
/**render({ctx}: Canvas) {
const satisfaction = this.satisfaction();
const dissatisfaction = 255 - satisfaction;
fillCircle(ctx, this.pos, particleSize, `rgb(${dissatisfaction}, 0, ${satisfaction})`);
strokeLine(ctx, this.pos, this.pos.add(this.dir.scale(30)));
}*/
render({ctx}: Canvas) {
const color = navigationColorMap[this.lastNavigation.type];
fillCircle(ctx, this.pos, particleSize, color);
strokeLine(ctx, this.pos, this.pos.add(this.dir.scale(30)));
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Canvas } from "@/canvas";
import { GameObject } from "@/common/gameobject";
import { Rect } from "@/common/rect";
import { Agent } from "@/game/swarm/agent";
const numAgents = 100;
export class Swarm extends GameObject {
rect: Rect;
agents: Agent[];
constructor(canvas: Canvas) {
super();
this.rect = new Rect(0, 0, canvas.width, canvas.height);
this.agents = [];
this.init();
// @ts-ignore
window.swarm = this;
}
init() {
const rectInTheMiddle = this.rect.translate(this.rect.tl.scale(0.25)).scale(0.5);
const cols = Math.floor(Math.sqrt(numAgents));
const rows = Math.floor(numAgents / cols);
const spacing = rectInTheMiddle.width / cols;
for (let x = 0; x < cols; x++) {
for (let y = 0; y < rows; y++) {
const pos = rectInTheMiddle.tl.add(x * spacing, y * spacing);
this.agents.push(new Agent(pos, this));
}
}
}
update(canvas: Canvas, delta: DOMHighResTimeStamp) {
// simulation seems to be breaking when browser goes to sleep
delta = Math.min(delta, 10);
this.agents.forEach(p => p.update(canvas, delta));
}
render(canvas: Canvas) {
this.agents.forEach(p => p.render(canvas));
}
}