diff --git a/src/camera.ts b/src/camera.ts index 3255584..3b3e8e9 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -94,13 +94,20 @@ export class Camera extends GameObject { lockToObj(object: GameObject) { this.lockedObject = object; - console.log("Watching", object); + console.info("Watching", object); } unlock() { this.lockedObject = null; } + reset() { + this.pos = new Vec(0, 0); + this.zoom = 1; + this.rotateAngle = 0; + this.applyTransformation(); + } + screenToScene(pos: Vec) { return pos .scale(1 / this.zoom) diff --git a/src/game/swarm/agent.ts b/src/game/swarm/agent.ts index 273f2e7..5888aeb 100644 --- a/src/game/swarm/agent.ts +++ b/src/game/swarm/agent.ts @@ -1,18 +1,11 @@ import { Canvas } from "@/canvas"; -import { clamp } from "@/common/functions"; +import { GameObject } from "@/common/gameobject"; 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 { 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 NavigatingDesire = { pos: Vec; @@ -26,13 +19,14 @@ const navigationColorMap = { behaveLikeFlockDoes: 'yellow', idling: 'purple' }; -export class Agent { +export class Agent extends GameObject { dir: Vec; - pos: Vec; swarm: Swarm; idlingGoal: Vec|null = null; lastNavigation: NavigatingDesire; + lastFlockCenter: Vec = new Vec; constructor(pos: Vec, swarm: Swarm) { + super(); this.pos = pos; this.swarm = swarm; this.lastNavigation = { @@ -43,28 +37,61 @@ export class Agent { this.dir = Vec.randomUnit(); } - update(canvas: Canvas, delta: DOMHighResTimeStamp) { + update({input}: Canvas) { const nextPos = this.pos.add(this.dir); - const navigations = []; - 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; + if (input.mouseDown) { + this.steerTowards(input.mouseWorldPos); + } else { + const navigations = []; + 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); + //const navigation = this.combinedNavigation(navigations, nextPos); + 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) { + if (targetDir.length() < this.swarm.config.epsilon) { return; } 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[]) { @@ -74,7 +101,7 @@ export class Agent { stayInBounds(nextPos: Vec): NavigatingDesire { 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 { pos: nextPos, urgency: 0, @@ -91,7 +118,7 @@ export class Agent { doNotBumpIntoFlock(nextPos: Vec): NavigatingDesire { const {agents} = this.swarm; 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 .filter(a => this.swarm.rect.contains(a.pos)); let closestAgent: Agent|null = null; @@ -108,9 +135,10 @@ export class Agent { }; } 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 { pos: this.pos.add(awayFromClosestAgentDir), - urgency: (desiredDistanceToEveryone - closestAgent.pos.distance(this.pos)) / desiredDistanceToEveryone, + urgency: urgency * urgency, type: 'doNotBumpIntoFlock' }; } @@ -118,16 +146,18 @@ export class Agent { keepFlockClose(nextPos: Vec): NavigatingDesire { const {agents} = this.swarm; 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 .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); + this.lastFlockCenter = center; + let urgency = Math.max(0, (distanceToCenter - this.swarm.config.flockIsCloseEnoughDistance) / this.swarm.config.flockPerceptionDistance); return { pos: center, - urgency: distanceToCenter / flockPerceptionDistance, + urgency: urgency * urgency, type: 'keepFlockClose' }; } @@ -135,7 +165,7 @@ export class Agent { behaveLikeFlockDoes(nextPos: Vec): NavigatingDesire { const {agents} = this.swarm; 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 .filter(a => this.swarm.rect.contains(a.pos)); const averageDir = visibleAgentsInBounds @@ -144,23 +174,23 @@ export class Agent { const angleDiff = this.dir.clockwiseAngleBetween(averageDir); return { pos: nextPos.add(averageDir), - urgency: behaveLikeFlockDoesUrgency * Math.abs(angleDiff) / Math.PI, + urgency: this.swarm.config.behaveLikeFlockDoesUrgency * Math.abs(angleDiff) / Math.PI, type: 'behaveLikeFlockDoes' }; } idling(nextPos: Vec): NavigatingDesire { - if (this.idlingGoal && this.idlingGoal.distance(nextPos) > idlingDistance) { + if (this.idlingGoal && this.idlingGoal.distance(nextPos) > this.swarm.config.idlingDistance) { return { pos: this.idlingGoal, - urgency: idlingUrgency, + urgency: this.swarm.config.idlingUrgency, type: 'idling' }; } this.idlingGoal = Vec.random(this.swarm.rect); return { pos: this.idlingGoal, - urgency: idlingUrgency, + urgency: this.swarm.config.idlingUrgency, type: 'idling' } } @@ -172,21 +202,24 @@ export class Agent { return 255 - this.lastNavigation.urgency * 255; } - /**render({ctx}: Canvas) { - - }*/ - - 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))); + render({ctx, input}: Canvas) { + if (input.mouseDown) { + fillCircle(ctx, this.pos, particleSize, 'gray'); } else { - const color = navigationColorMap[this.lastNavigation.type]; - fillCircle(ctx, this.pos, particleSize, color); + if (this.swarm.config.renderStyle === 'satisfaction') { + 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))); } - + if (this.swarm.config.showFlockCenter) { + strokeCircle(ctx, this.lastFlockCenter, particleSize, 'orange'); + } } } diff --git a/src/game/swarm/swarm.ts b/src/game/swarm/swarm.ts index 9f29462..685ce1e 100644 --- a/src/game/swarm/swarm.ts +++ b/src/game/swarm/swarm.ts @@ -3,34 +3,58 @@ import { GameObject } from "@/common/gameobject"; import { Rect } from "@/common/rect"; import { Vec } from "@/common/vec"; import { Agent } from "@/game/swarm/agent"; +import { strokeCircle } from "@/graphics"; 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 { rect: Rect; agents: Agent[]; - renderStyle: 'satisfaction'|'navigationMode' = 'navigationMode'; + selectedAgent: Agent | null = null; + config: Config; constructor(canvas: Canvas) { super(); this.rect = new Rect(0, 0, canvas.width, canvas.height); this.agents = []; - this.init(); + this.config = new Config; + this.init(canvas); this.initUI(canvas); // @ts-ignore window.swarm = this; } - init() { + init(canvas: Canvas) { + canvas.camera.reset(); + this.agents.forEach(a => canvas.remove(a)); this.agents = []; + this.selectedAgent = null; 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 cols = Math.floor(Math.sqrt(this.config.numAgents)); + const rows = Math.floor(this.config.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)); + 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) { const buttonSize = new Vec(100, 40); 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), () => { - 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), () => { - numAgents -= Math.max(1, numAgents / 10); - this.init(); + this.config.numAgents -= Math.max(1, this.config.numAgents / 10); + this.init(canvas); }), new Button("More Agents", Rect.bySize(new Vec(110, 60), buttonSize), () => { - numAgents += Math.max(1, numAgents / 10); - this.init(); + this.config.numAgents += Math.max(1, this.config.numAgents / 10); + 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)); + 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) { - // 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)); + 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'); + } } } diff --git a/src/input.ts b/src/input.ts index 8f1a03f..7ebc5a4 100644 --- a/src/input.ts +++ b/src/input.ts @@ -34,33 +34,48 @@ export class Input { document.addEventListener('keyup', event => { this.keydowns.delete(event.key); }); - document.addEventListener('mousemove', event => { - this._mousePos = new Vec(event.clientX, event.clientY); - }); - document.addEventListener('mouseup', event => { - if (event.button === 0) { + + const mouseUp = (event: MouseEvent|TouchEvent) => { + const button = event instanceof MouseEvent ? event.button : 0; + if (button === 0) { this._mouseDown = false; - } else if (event.button === 1) { + } else if (button === 1) { this._middleMouseDown = false; this._middleMousePressed = false; - } else if (event.button === 2) { + } else if (button === 2) { this._rightMouseDown = false; } - }); - document.addEventListener('mousedown', event => { - if (event.button === 0) { + } + const mouseMove = (event: MouseEvent|TouchEvent) => { + 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._mousePressed = true; - } else if (event.button === 1) { + } else if (button === 1) { this._middleMouseDown = true; this._middleMousePressed = true; event.preventDefault(); - } else if (event.button === 2) { + } else if (button === 2) { this._rightMouseDown = true; this._rightMousePressed = true; 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 => { event.preventDefault(); }); @@ -117,7 +132,11 @@ export class Input { document.addEventListener('click', callback); } + onRightClick(callback: (event: MouseEvent) => void) { + document.addEventListener('contextmenu', callback); + } + onWheel(callback: (event: WheelEvent) => void) { document.addEventListener('wheel', callback); } -} \ No newline at end of file +}