import Rx, { Observable } from 'rxjs'; import { BEHAVIOR, ENTITIES, RAD } from './enums'; import Store from './store'; const random = { bool: (weight) => Math.random() < (weight || 0.5), color: () => `rgb( ${Math.floor(Math.random() * 170)}, ${Math.floor(Math.random() * 170)}, ${Math.floor(Math.random() * 170)} )`, id: () => String.fromCharCode( random.num(65, 90), random.num(97, 122), random.num(97, 122), random.num(97, 122), random.num(97, 122), random.num(97, 122) ), num: (min, max) => min + Math.round(Math.random() * (max - min)), } // ===== Constructor ===== function Particle(parent, bounds, config, globalGrid) { Object.defineProperty(this, 'config', { value: Object.assign({}, { behavior: BEHAVIOR.COHESION, bounds, color: random.color(), gridSize: 10, randomize: true, showMovementCircle: false, showVisionGrid: false, speed: 4, visionRadius: 200 }, config) }); Object.defineProperty(this, 'grids', { value: { global: globalGrid || {}, vision: createVisionGrid(this.config) } }); Object.defineProperty(this, 'id', { value: random.id(6) }); Object.defineProperty(this, 'nodes', { value: { body: createBodyNode(this.config), circle: undefined, container: createContainerNode(this.config, this.id), parent, visionGrid: undefined, } }); // TODO encapsulate better this.isLeader = false; this.leader = null; this.nodes.container.appendChild(this.nodes.body); parent.appendChild(this.nodes.container); this.arc = createArc(bounds, this.grids); this.updateConfig(this.config); this.nextFrame(globalGrid); }; // ===== PROTOTYPE ===== Particle.prototype.remove = function() { this.nodes.parent.removeChild(this.nodes.container); return this; } Particle.prototype.nextFrame = function(globalGrid) { // Randomly change radius and rotation direction. if (this.arc.length <= 0 && this.config.randomize) { this.arc = randomizeArc(this.arc); } this.arc = step(this.arc, this.config); if (this.leader !== null) { this.arc = followArc(this.arc, this.leader.arc); } this.grids.global = globalGrid; this.grids.vision = updateVisionGrid(this.arc, this.config, this.grids); const { hazards, particles } = look(this.arc, this.grids); if (this.leader === null && particles.length > 0) { const candidates = particles .filter(v => v.leader ? (v.leader.id !== this.id) : true); const leader = candidates.find(v => v.isLeader) || candidates[0]; if (leader !== undefined) { leader.isLeader = true; console.warn(`${particles[0].id} is now a leader`); console.log(`${this.id} is now following ${leader.id}`); this.isLeader = false; this.leader = leader; } } // if (hazards.length) { // this.arc = evade(this.arc, this.grids.vision); // } repaintContainer(this.nodes.container, this.arc); repaintBody(this.nodes.body, this.arc, this.isLeader); repaintCircle(this.nodes.circle, this.arc); repaintVisionGrid(this.nodes.visionGrid, this.arc, this.grids); } Particle.prototype.updateConfig = function(config) { Object.assign(this.config, config); const { showMovementCircle, showVisionGrid } = this.config; if (showMovementCircle === true && this.nodes.circle === undefined) { this.nodes.circle = createCircleNode(config); this.nodes.container.appendChild(this.nodes.circle); } if (showMovementCircle === false && this.nodes.circle !== undefined) { this.nodes.container.removeChild(this.nodes.circle); delete this.nodes.circle; } if (showVisionGrid === true && this.nodes.visionGrid === undefined) { this.nodes.visionGrid = createVisionGridNodes(this.config, this.grids, this.nodes); } if (showVisionGrid === false && this.nodes.visionGrid !== undefined) { delete this.nodex.visionGrid; } } // ===== CREATION ===== function createArc(bounds, grids) { let arc = { centerX: random.num(0, bounds.width), centerY: random.num(0, bounds.height), clockwise: random.bool(), endX: 0, endY: 0, length: random.num(RAD.t90, RAD.t360), radius: random.num(100, 200), theta: random.num(RAD.t90, RAD.t360), }; arc.endX = arc.centerX + arc.radius * Math.cos(arc.theta); arc.endY = arc.centerY - arc.radius * Math.sin(arc.theta); arc = overflowArc(arc, bounds); const x = arc.endX - arc.endX % 5; const y = arc.endY - arc.endY % 5; // If starting in a hazard, recurse. if (grids.global[x] !== undefined && grids.global[x][y] !== undefined) { arc = createArc(bounds, grids); } return arc; } function createBodyNode(config) { const node = document.createElement('div'); node.className = 'particle-body'; node.style.backgroundColor = config.color; return node; } function createCircleNode(config) { if (config.showMovementCircle === false) { return undefined; } const node = document.createElement('div'); node.className = 'particle-movement-circle'; node.style.borderColor = config.color; return node; } function createContainerNode(config, id) { const node = document.createElement('div'); node.className = 'particle-container'; node.id = id; return node; } function createVisionGrid(config) { const { gridSize: side, visionRadius: radius } = config; const r0 = radius; const r1 = 20; const points = []; for (let x = -radius; x <= radius; x += side) { for (let y = -radius; y <= radius; y += side) { // Omit large slices of unused circle if (x > y || x < -y) { continue; } // Include vision band const r = Math.pow(Math.pow(x, 2) + Math.pow(y, 2), 0.5); if (r > r0 || r < r1) { continue; } let alpha = Math.atan(y / x); if (x < 0) { alpha += RAD.t180; } points.push({ x, y, r, alpha, touch: false }); } } return points; } function createVisionGridNodes(config, grids, nodes) { if (config.showVisionGrid === false) { return undefined; } return grids.vision.reduce((acc, { x, y }) => { const div = document.createElement('div'); div.className = 'particle-vision-dot'; div.style.backgroundColor = config.color; nodes.container.appendChild(div); acc.push(div); return acc; }, []); } // ===== CALCULATIONS ===== function step(arc, { bounds, speed }) { // Ensure constant velocity and theta between 0 and 2π. const delta = speed / arc.radius; arc.length -= delta; arc.theta += (arc.clockwise ? -delta : +delta); arc.theta = (arc.theta > 0 ? arc.theta % RAD.t360 : RAD.t360 + arc.theta); arc.endX = arc.centerX + arc.radius * Math.cos(arc.theta); // TODO perf here arc.endY = arc.centerY - arc.radius * Math.sin(arc.theta); // TODO perf here // Overflow. arc = overflowArc(arc, bounds); return arc; } function randomizeArc(arc) { arc.length = random.num(RAD.t90, RAD.t360); arc = moveArc(arc, random.num(100, 200)); if (random.bool(0.8)) { arc = reverseArc(arc); } return arc; } function overflowArc(arc, bounds) { if (arc.endX < 0) { arc.endX += bounds.width; arc.centerX += bounds.width } else if (arc.endX > bounds.width) { arc.endX -= bounds.width; arc.centerX -= bounds.width } if (arc.endY < 0) { arc.endY += bounds.height; arc.centerY += bounds.height } else if (arc.endY > bounds.height) { arc.endY -= bounds.height; arc.centerY -= bounds.height } return arc; } function moveArc(arc, newRadius) { const r0 = arc.radius; const r1 = newRadius; // Moves arc center to new radius while keeping theta constant. arc.centerX -= (r1 - r0) * Math.cos(arc.theta); // TODO perf here arc.centerY += (r1 - r0) * Math.sin(arc.theta); // TODO perf here arc.radius = r1; return arc; } function reverseArc(arc) { arc.clockwise = !arc.clockwise; arc.theta = (arc.theta + RAD.t180) % RAD.t360; arc.centerX -= (2 * arc.radius) * Math.cos(arc.theta); // TODO perf here arc.centerY += (2 * arc.radius) * Math.sin(arc.theta); // TODO perf here return arc; } function followArc(arc, arcToFollow) { if (arc.clockwise !== arcToFollow.clockwise) { arc = reverseArc(arc); } // if (Math.abs(arc.theta - arcToFollow.theta) > 0.1) { // arc = moveArc(arc, 20); // } else { // arc = moveArc(arc, arcToFollow.radius); // } return arc; } function updateVisionGrid(arc, config, grids) { const { global, vision } = grids; return vision.reduce((acc, point) => { const rad = arc.clockwise ? point.alpha - arc.theta : point.alpha - arc.theta + RAD.t180; point.x = point.r * Math.cos(rad); point.y = point.r * Math.sin(rad); return acc.concat(point); }, []); } // ===== ACTIONS ===== function look(arc, grids) { const { global, vision } = grids; return vision.reduce((acc, point) => { const x = arc.endX + point.x; const y = arc.endY + point.y; const p = global.getPoint({ x, y, type: ENTITIES.PARTICLE }); if (p) { acc.particles.push(p); } // if (global.getPoint({ x, y, type: ENTITIES.HAZARD })) { // acc.hazards.push({ x, y }); // } return acc; }, { hazards: [], particles: [] }); } function evade(arc, visionGrid) { // const danger = visionGrid.reduce((acc, v) => acc || v.touch, false); // // if (danger === false) { // return arc; // } // // const evasionArc = moveArc(arc, 20); // evasionArc.length = 1; // // return evasionArc; } // ===== RENDERING ===== function repaintContainer(node, arc) { node.style.left = `${arc.endX}px`; node.style.top = `${arc.endY}px`; } function repaintBody(node, arc, isLeader) { const rad = arc.clockwise ? RAD.t180 - arc.theta : RAD.t360 - arc.theta; node.style.transform = `rotate(${rad + RAD.t45}rad)`; isLeader ? node.style.outline = '1px solid red' : node.style.outline = ''; } function repaintCircle(node, arc) { if (node === undefined) { return; } node.style.width = `${2 * arc.radius}px`; node.style.height = `${2 * arc.radius}px`; node.style.left = `-${arc.radius + arc.radius * Math.cos(arc.theta)}px`; // TODO perf here node.style.top = `-${arc.radius - arc.radius * Math.sin(arc.theta)}px`; // TODO perf here node.style.borderRadius = `${arc.radius}px`; } function repaintVisionGrid(nodes, arc, grids) { if (nodes === undefined) { return; } grids.vision.forEach(({ x, y, touch }, i) => { nodes[i].style.left = `${x}px`; nodes[i].style.top = `${y}px`; nodes[i].style.border = (touch ? '2px solid red' : '0'); }); } export default Particle;