Some configs

This commit is contained in:
Schmop 2024-05-04 22:47:37 +02:00
parent 666b11a477
commit f205d23179
4 changed files with 190 additions and 80 deletions

View File

@ -94,13 +94,20 @@ export class Camera extends GameObject {
lockToObj(object: GameObject) { lockToObj(object: GameObject) {
this.lockedObject = object; this.lockedObject = object;
console.log("Watching", object); console.info("Watching", object);
} }
unlock() { unlock() {
this.lockedObject = null; this.lockedObject = null;
} }
reset() {
this.pos = new Vec(0, 0);
this.zoom = 1;
this.rotateAngle = 0;
this.applyTransformation();
}
screenToScene(pos: Vec) { screenToScene(pos: Vec) {
return pos return pos
.scale(1 / this.zoom) .scale(1 / this.zoom)

View File

@ -1,18 +1,11 @@
import { Canvas } from "@/canvas"; import { Canvas } from "@/canvas";
import { clamp } from "@/common/functions"; import { GameObject } from "@/common/gameobject";
import { Vec } from "@/common/vec"; import { Vec } from "@/common/vec";
import { desiredDensity, particleSize } from "@/game/fluids/fluids"; import { particleSize } from "@/game/fluids/fluids";
import { Swarm } from "@/game/swarm/swarm"; import { Swarm } from "@/game/swarm/swarm";
import { fillCircle, strokeLine } from "@/graphics"; import { fillCircle, strokeCircle, 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 NavigationType = 'stayInBounds' | 'doNotBumpIntoFlock' | 'keepFlockClose' | 'behaveLikeFlockDoes' | 'idling';
export type NavigatingDesire = { export type NavigatingDesire = {
pos: Vec; pos: Vec;
@ -26,13 +19,14 @@ const navigationColorMap = {
behaveLikeFlockDoes: 'yellow', behaveLikeFlockDoes: 'yellow',
idling: 'purple' idling: 'purple'
}; };
export class Agent { export class Agent extends GameObject {
dir: Vec; dir: Vec;
pos: Vec;
swarm: Swarm; swarm: Swarm;
idlingGoal: Vec|null = null; idlingGoal: Vec|null = null;
lastNavigation: NavigatingDesire; lastNavigation: NavigatingDesire;
lastFlockCenter: Vec = new Vec;
constructor(pos: Vec, swarm: Swarm) { constructor(pos: Vec, swarm: Swarm) {
super();
this.pos = pos; this.pos = pos;
this.swarm = swarm; this.swarm = swarm;
this.lastNavigation = { this.lastNavigation = {
@ -43,28 +37,61 @@ export class Agent {
this.dir = Vec.randomUnit(); this.dir = Vec.randomUnit();
} }
update(canvas: Canvas, delta: DOMHighResTimeStamp) { update({input}: Canvas) {
const nextPos = this.pos.add(this.dir); const nextPos = this.pos.add(this.dir);
const navigations = []; if (input.mouseDown) {
navigations.push(this.stayInBounds(nextPos)); this.steerTowards(input.mouseWorldPos);
navigations.push(this.doNotBumpIntoFlock(nextPos)); } else {
navigations.push(this.keepFlockClose(nextPos)); const navigations = [];
navigations.push(this.behaveLikeFlockDoes(nextPos)); navigations.push(this.stayInBounds(nextPos));
navigations.push(this.idling(nextPos)); navigations.push(this.doNotBumpIntoFlock(nextPos));
const navigation = this.mostUrgentNavigation(navigations); navigations.push(this.keepFlockClose(nextPos));
this.steerTowards(navigation.pos); navigations.push(this.behaveLikeFlockDoes(nextPos));
this.lastNavigation = navigation; navigations.push(this.idling(nextPos));
const navigation = this.mostUrgentNavigation(navigations);
//const navigation = this.combinedNavigation(navigations, nextPos);
this.steerTowards(navigation.pos);
this.lastNavigation = navigation;
}
this.pos = nextPos; this.pos = nextPos;
} }
steerTowards(target: Vec) { steerTowards(target: Vec) {
const targetDir = target.sub(this.pos); const targetDir = target.sub(this.pos);
// no division by zero! // no division by zero!
if (targetDir.length() < epsilon) { if (targetDir.length() < this.swarm.config.epsilon) {
return; return;
} }
const alpha = this.dir.clockwiseAngleBetween(targetDir.normalize()); const alpha = this.dir.clockwiseAngleBetween(targetDir.normalize());
this.dir = this.dir.rotate(Math.min(allowedSteer, Math.abs(alpha)) * Math.sign(alpha)).normalize(); this.dir = this.dir.rotate(Math.min(this.swarm.config.allowedSteer, Math.abs(alpha)) * Math.sign(alpha)).normalize();
}
combinedNavigation(navigations: NavigatingDesire[], nextPos: Vec) {
const mostUrgent = this.mostUrgentNavigation(navigations);
if (mostUrgent.urgency === 1) {
return mostUrgent;
}
const weightedDir = navigations
.reduce((sum, n) => {
const diff = n.pos.sub(this.pos);
if (diff.length() < this.swarm.config.epsilon) {
return sum;
}
const dir = diff.normalize();
return sum.add(dir.scale(n.urgency));
}, new Vec);
let pos = this.pos.add(weightedDir.normalize());
if (pos.sub(this.pos).length() < this.swarm.config.epsilon) {
pos = nextPos;
}
const urgency = navigations
.reduce((sum, n) => sum + n.urgency, 0) / navigations.length;
return {
pos,
urgency,
type: mostUrgent.type,
};
} }
mostUrgentNavigation(navigations: NavigatingDesire[]) { mostUrgentNavigation(navigations: NavigatingDesire[]) {
@ -74,7 +101,7 @@ export class Agent {
stayInBounds(nextPos: Vec): NavigatingDesire { stayInBounds(nextPos: Vec): NavigatingDesire {
const {rect} = this.swarm; const {rect} = this.swarm;
if (rect.contains(this.pos.add(this.dir.scale(padding)))) { if (rect.contains(this.pos.add(this.dir.scale(this.swarm.config.padding)))) {
return { return {
pos: nextPos, pos: nextPos,
urgency: 0, urgency: 0,
@ -91,7 +118,7 @@ export class Agent {
doNotBumpIntoFlock(nextPos: Vec): NavigatingDesire { doNotBumpIntoFlock(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm; const {agents} = this.swarm;
const visibleAgentsNotMe = agents const visibleAgentsNotMe = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance && a !== this); .filter(a => a.pos.distance(this.pos) < this.swarm.config.flockPerceptionDistance && a !== this);
const visibleAgentsInBounds = visibleAgentsNotMe const visibleAgentsInBounds = visibleAgentsNotMe
.filter(a => this.swarm.rect.contains(a.pos)); .filter(a => this.swarm.rect.contains(a.pos));
let closestAgent: Agent|null = null; let closestAgent: Agent|null = null;
@ -108,9 +135,10 @@ export class Agent {
}; };
} }
const awayFromClosestAgentDir = this.pos.sub(closestAgent.pos).normalize(); const awayFromClosestAgentDir = this.pos.sub(closestAgent.pos).normalize();
const urgency = Math.max(0, (this.swarm.config.desiredDistanceToEveryone - closestAgent.pos.distance(this.pos)) / this.swarm.config.desiredDistanceToEveryone);
return { return {
pos: this.pos.add(awayFromClosestAgentDir), pos: this.pos.add(awayFromClosestAgentDir),
urgency: (desiredDistanceToEveryone - closestAgent.pos.distance(this.pos)) / desiredDistanceToEveryone, urgency: urgency * urgency,
type: 'doNotBumpIntoFlock' type: 'doNotBumpIntoFlock'
}; };
} }
@ -118,16 +146,18 @@ export class Agent {
keepFlockClose(nextPos: Vec): NavigatingDesire { keepFlockClose(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm; const {agents} = this.swarm;
const visibleAgents = agents const visibleAgents = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance); .filter(a => a.pos.distance(this.pos) < this.swarm.config.flockPerceptionDistance);
const visibleAgentsInBounds = visibleAgents const visibleAgentsInBounds = visibleAgents
.filter(a => this.swarm.rect.contains(a.pos)); .filter(a => this.swarm.rect.contains(a.pos));
const center = visibleAgentsInBounds const center = visibleAgentsInBounds
.reduce((sum, a) => sum.add(a.pos), new Vec) .reduce((sum, a) => sum.add(a.pos), new Vec)
.scale(1 / visibleAgentsInBounds.length); .scale(1 / visibleAgentsInBounds.length);
const distanceToCenter = this.pos.distance(center); const distanceToCenter = this.pos.distance(center);
this.lastFlockCenter = center;
let urgency = Math.max(0, (distanceToCenter - this.swarm.config.flockIsCloseEnoughDistance) / this.swarm.config.flockPerceptionDistance);
return { return {
pos: center, pos: center,
urgency: distanceToCenter / flockPerceptionDistance, urgency: urgency * urgency,
type: 'keepFlockClose' type: 'keepFlockClose'
}; };
} }
@ -135,7 +165,7 @@ export class Agent {
behaveLikeFlockDoes(nextPos: Vec): NavigatingDesire { behaveLikeFlockDoes(nextPos: Vec): NavigatingDesire {
const {agents} = this.swarm; const {agents} = this.swarm;
const visibleAgents = agents const visibleAgents = agents
.filter(a => a.pos.distance(this.pos) < flockPerceptionDistance); .filter(a => a.pos.distance(this.pos) < this.swarm.config.flockPerceptionDistance);
const visibleAgentsInBounds = visibleAgents const visibleAgentsInBounds = visibleAgents
.filter(a => this.swarm.rect.contains(a.pos)); .filter(a => this.swarm.rect.contains(a.pos));
const averageDir = visibleAgentsInBounds const averageDir = visibleAgentsInBounds
@ -144,23 +174,23 @@ export class Agent {
const angleDiff = this.dir.clockwiseAngleBetween(averageDir); const angleDiff = this.dir.clockwiseAngleBetween(averageDir);
return { return {
pos: nextPos.add(averageDir), pos: nextPos.add(averageDir),
urgency: behaveLikeFlockDoesUrgency * Math.abs(angleDiff) / Math.PI, urgency: this.swarm.config.behaveLikeFlockDoesUrgency * Math.abs(angleDiff) / Math.PI,
type: 'behaveLikeFlockDoes' type: 'behaveLikeFlockDoes'
}; };
} }
idling(nextPos: Vec): NavigatingDesire { idling(nextPos: Vec): NavigatingDesire {
if (this.idlingGoal && this.idlingGoal.distance(nextPos) > idlingDistance) { if (this.idlingGoal && this.idlingGoal.distance(nextPos) > this.swarm.config.idlingDistance) {
return { return {
pos: this.idlingGoal, pos: this.idlingGoal,
urgency: idlingUrgency, urgency: this.swarm.config.idlingUrgency,
type: 'idling' type: 'idling'
}; };
} }
this.idlingGoal = Vec.random(this.swarm.rect); this.idlingGoal = Vec.random(this.swarm.rect);
return { return {
pos: this.idlingGoal, pos: this.idlingGoal,
urgency: idlingUrgency, urgency: this.swarm.config.idlingUrgency,
type: 'idling' type: 'idling'
} }
} }
@ -172,21 +202,24 @@ export class Agent {
return 255 - this.lastNavigation.urgency * 255; return 255 - this.lastNavigation.urgency * 255;
} }
/**render({ctx}: Canvas) { render({ctx, input}: Canvas) {
if (input.mouseDown) {
}*/ fillCircle(ctx, this.pos, particleSize, 'gray');
render({ctx}: Canvas) {
if (this.swarm.renderStyle === 'satisfaction') {
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)));
} else { } else {
const color = navigationColorMap[this.lastNavigation.type]; if (this.swarm.config.renderStyle === 'satisfaction') {
fillCircle(ctx, this.pos, particleSize, color); const satisfaction = this.satisfaction();
const dissatisfaction = 255 - satisfaction;
fillCircle(ctx, this.pos, particleSize, `rgb(${dissatisfaction}, 0, ${satisfaction})`);
} else {
const color = navigationColorMap[this.lastNavigation.type];
fillCircle(ctx, this.pos, particleSize, color);
}
}
if (this.swarm.config.showDirections) {
strokeLine(ctx, this.pos, this.pos.add(this.dir.scale(30))); strokeLine(ctx, this.pos, this.pos.add(this.dir.scale(30)));
} }
if (this.swarm.config.showFlockCenter) {
strokeCircle(ctx, this.lastFlockCenter, particleSize, 'orange');
}
} }
} }

View File

@ -3,34 +3,58 @@ import { GameObject } from "@/common/gameobject";
import { Rect } from "@/common/rect"; import { Rect } from "@/common/rect";
import { Vec } from "@/common/vec"; import { Vec } from "@/common/vec";
import { Agent } from "@/game/swarm/agent"; import { Agent } from "@/game/swarm/agent";
import { strokeCircle } from "@/graphics";
import Button from "@/ui/button"; import Button from "@/ui/button";
let numAgents = 20; export type RenderStyle = 'satisfaction'|'navigationMode';
export class Config {
numAgents: number = 20;
padding: number = 50;
allowedSteer: number = 5 / 180;
idlingUrgency: number = 0.001;
idlingDistance: number = 20;
desiredDistanceToEveryone: number = 50;
renderStyle: RenderStyle = 'satisfaction';
showDirections: boolean = false;
showFlockCenter: boolean = false;
flockPerceptionDistance: number = 100;
flockIsCloseEnoughDistance: number = 20;
behaveLikeFlockDoesUrgency: number = 0.8;
epsilon: number = 1e-5;
}
export class Swarm extends GameObject { export class Swarm extends GameObject {
rect: Rect; rect: Rect;
agents: Agent[]; agents: Agent[];
renderStyle: 'satisfaction'|'navigationMode' = 'navigationMode'; selectedAgent: Agent | null = null;
config: Config;
constructor(canvas: Canvas) { constructor(canvas: Canvas) {
super(); super();
this.rect = new Rect(0, 0, canvas.width, canvas.height); this.rect = new Rect(0, 0, canvas.width, canvas.height);
this.agents = []; this.agents = [];
this.init(); this.config = new Config;
this.init(canvas);
this.initUI(canvas); this.initUI(canvas);
// @ts-ignore // @ts-ignore
window.swarm = this; window.swarm = this;
} }
init() { init(canvas: Canvas) {
canvas.camera.reset();
this.agents.forEach(a => canvas.remove(a));
this.agents = []; this.agents = [];
this.selectedAgent = null;
const rectInTheMiddle = this.rect.translate(this.rect.tl.scale(0.25)).scale(0.5); const rectInTheMiddle = this.rect.translate(this.rect.tl.scale(0.25)).scale(0.5);
const cols = Math.floor(Math.sqrt(numAgents)); const cols = Math.floor(Math.sqrt(this.config.numAgents));
const rows = Math.floor(numAgents / cols); const rows = Math.floor(this.config.numAgents / cols);
const spacing = rectInTheMiddle.width / cols; const spacing = rectInTheMiddle.width / cols;
for (let x = 0; x < cols; x++) { for (let x = 0; x < cols; x++) {
for (let y = 0; y < rows; y++) { for (let y = 0; y < rows; y++) {
const pos = rectInTheMiddle.tl.add(x * spacing, y * spacing); const pos = rectInTheMiddle.tl.add(x * spacing, y * spacing);
this.agents.push(new Agent(pos, this)); const agent = new Agent(pos, this);
this.agents.push(agent);
canvas.add(agent);
} }
} }
} }
@ -38,29 +62,56 @@ export class Swarm extends GameObject {
initUI(canvas: Canvas) { initUI(canvas: Canvas) {
const buttonSize = new Vec(100, 40); const buttonSize = new Vec(100, 40);
const buttons = [ const buttons = [
new Button("Reset", Rect.bySize(new Vec(10, 10), buttonSize), () => this.init()), new Button("Reset", Rect.bySize(new Vec(10, 10), buttonSize), () => this.init(canvas)),
new Button("Toggle Colors", Rect.bySize(new Vec(110, 10), buttonSize), () => { new Button("Toggle Colors", Rect.bySize(new Vec(110, 10), buttonSize), () => {
this.renderStyle = this.renderStyle === 'satisfaction' ? 'navigationMode' : 'satisfaction'; this.config.renderStyle = this.config.renderStyle === 'satisfaction' ? 'navigationMode' : 'satisfaction';
}), }),
new Button("Less Agents", Rect.bySize(new Vec(10, 60), buttonSize), () => { new Button("Less Agents", Rect.bySize(new Vec(10, 60), buttonSize), () => {
numAgents -= Math.max(1, numAgents / 10); this.config.numAgents -= Math.max(1, this.config.numAgents / 10);
this.init(); this.init(canvas);
}), }),
new Button("More Agents", Rect.bySize(new Vec(110, 60), buttonSize), () => { new Button("More Agents", Rect.bySize(new Vec(110, 60), buttonSize), () => {
numAgents += Math.max(1, numAgents / 10); this.config.numAgents += Math.max(1, this.config.numAgents / 10);
this.init(); this.init(canvas);
}),
new Button("toggle Directions", Rect.bySize(new Vec(10, 110), buttonSize), () => {
this.config.showDirections = !this.config.showDirections;
}),
new Button("toggle Flock Center", Rect.bySize(new Vec(110, 110), buttonSize), () => {
this.config.showFlockCenter = !this.config.showFlockCenter;
}), }),
] ]
buttons.forEach(b => canvas.add(b, Canvas.LAYER_UI)); buttons.forEach(b => canvas.add(b, Canvas.LAYER_UI));
canvas.input.onRightClick((event ) => {
const mousePos = canvas.input.mouseWorldPos;
this.selectedAgent = this.agents.reduce((closest: Agent|null, a) => {
if (!closest) {
return a;
}
const dist = a.pos.distance(mousePos);
if (dist < closest.pos.distance(mousePos)) {
return a;
}
return closest;
}, null);
if (this.selectedAgent) {
canvas.camera.lockToObj(this.selectedAgent);
}
});
} }
update(canvas: Canvas, delta: DOMHighResTimeStamp) { 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) { render(canvas: Canvas) {
this.agents.forEach(p => p.render(canvas)); const {input, ctx} = canvas;
if (input.mouseDown) {
strokeCircle(ctx, input.mouseWorldPos, 40, 'white');
}
if (this.selectedAgent) {
canvas.ctx.strokeStyle = "darkgrey"
canvas.ctx.strokeRect(this.rect.x, this.rect.y, this.rect.width, this.rect.height);
strokeCircle(ctx, this.selectedAgent.pos, 20, 'purple');
}
} }
} }

View File

@ -34,33 +34,48 @@ export class Input {
document.addEventListener('keyup', event => { document.addEventListener('keyup', event => {
this.keydowns.delete(event.key); this.keydowns.delete(event.key);
}); });
document.addEventListener('mousemove', event => {
this._mousePos = new Vec(event.clientX, event.clientY); const mouseUp = (event: MouseEvent|TouchEvent) => {
}); const button = event instanceof MouseEvent ? event.button : 0;
document.addEventListener('mouseup', event => { if (button === 0) {
if (event.button === 0) {
this._mouseDown = false; this._mouseDown = false;
} else if (event.button === 1) { } else if (button === 1) {
this._middleMouseDown = false; this._middleMouseDown = false;
this._middleMousePressed = false; this._middleMousePressed = false;
} else if (event.button === 2) { } else if (button === 2) {
this._rightMouseDown = false; this._rightMouseDown = false;
} }
}); }
document.addEventListener('mousedown', event => { const mouseMove = (event: MouseEvent|TouchEvent) => {
if (event.button === 0) { if (event instanceof MouseEvent) {
this._mousePos = new Vec(event.clientX, event.clientY);
} else {
this._mousePos = new Vec(event.touches[0].clientX, event.touches[0].clientY);
}
}
const mouseDown = (event: MouseEvent|TouchEvent) => {
mouseMove(event);
const button = event instanceof MouseEvent ? event.button : 0;
if (button === 0) {
this._mouseDown = true; this._mouseDown = true;
this._mousePressed = true; this._mousePressed = true;
} else if (event.button === 1) { } else if (button === 1) {
this._middleMouseDown = true; this._middleMouseDown = true;
this._middleMousePressed = true; this._middleMousePressed = true;
event.preventDefault(); event.preventDefault();
} else if (event.button === 2) { } else if (button === 2) {
this._rightMouseDown = true; this._rightMouseDown = true;
this._rightMousePressed = true; this._rightMousePressed = true;
event.preventDefault(); event.preventDefault();
} }
}); }
document.addEventListener('mousedown', mouseDown);
document.addEventListener('mousemove', mouseMove);
document.addEventListener('mouseup', mouseUp);
document.addEventListener('touchstart', mouseDown);
document.addEventListener('touchmove', mouseMove);
document.addEventListener('touchend', mouseUp);
document.addEventListener('touchcancel', mouseUp);
document.addEventListener('contextmenu', event => { document.addEventListener('contextmenu', event => {
event.preventDefault(); event.preventDefault();
}); });
@ -117,7 +132,11 @@ export class Input {
document.addEventListener('click', callback); document.addEventListener('click', callback);
} }
onRightClick(callback: (event: MouseEvent) => void) {
document.addEventListener('contextmenu', callback);
}
onWheel(callback: (event: WheelEvent) => void) { onWheel(callback: (event: WheelEvent) => void) {
document.addEventListener('wheel', callback); document.addEventListener('wheel', callback);
} }
} }