├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── index.js ├── package.json ├── screenshots ├── 0qNNe.png ├── 4OkB2.png └── QrNxq.png ├── scripts └── deploy ├── src ├── mouse-position-driver.js └── time-driver.js └── styles.css /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Cycle.js Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # boids 2 | Boids in Cycle.js (bird flocking simulator) 3 | 4 | [Check it out](https://cyclejs-community.github.io/boids/). 5 | === 6 | 7 | ![Screenshot 1](https://raw.githubusercontent.com/cyclejs-community/boids/master/screenshots/0qNNe.png) 8 | ![Screenshot 2](https://raw.githubusercontent.com/cyclejs-community/boids/master/screenshots/4OkB2.png) 9 | ![Screenshot 3](https://raw.githubusercontent.com/cyclejs-community/boids/master/screenshots/QrNxq.png) 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Boids 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {run} from '@cycle/xstream-run'; 2 | import {makeDOMDriver, div, input} from '@cycle/dom'; 3 | import xs from 'xstream'; 4 | import _ from 'lodash'; 5 | import uuid from 'node-uuid'; 6 | 7 | import timeDriver from './src/time-driver'; 8 | import mousePositionDriver from './src/mouse-position-driver'; 9 | 10 | const FRAME_RATE = 1000 / 60; 11 | 12 | // subtract by 1 because otherwise the spawn point is the same as the mouse start 13 | // position and the boids don't move until the mouse moves 14 | const BOID_SPAWN_POINT = { 15 | x: window.innerWidth / 2 - 1, 16 | y: window.innerHeight / 2 - 1 17 | }; 18 | 19 | const LIGHTNESS_MIN = 30; 20 | const LIGHTNESS_MAX = 100; 21 | const LIGHTNESS_RANGE = LIGHTNESS_MAX - LIGHTNESS_MIN; 22 | const LIGHTNESS_FALLOFF = 800; 23 | 24 | const BOID_COUNT = 75; 25 | const FRICTION = 0.98; 26 | 27 | function Boid () { 28 | return { 29 | position: Object.assign({}, BOID_SPAWN_POINT), 30 | velocity: {x: 0, y: 0}, 31 | hue: 276, 32 | key: uuid.v4() 33 | }; 34 | } 35 | 36 | function makeflock (count) { 37 | return _.range(count).map(Boid); 38 | } 39 | 40 | function renderBoid (boid, mousePosition, delta) { 41 | const angle = Math.atan2(boid.velocity.y, boid.velocity.x); 42 | 43 | const speed = Math.abs(boid.velocity.x) + Math.abs(boid.velocity.y); 44 | 45 | const scale = speed / 30 * delta; 46 | 47 | const distanceVector = { 48 | x: Math.abs(boid.position.x - mousePosition.x), 49 | y: Math.abs(boid.position.y - mousePosition.y) 50 | }; 51 | 52 | const distanceToMouse = Math.sqrt( 53 | Math.pow(distanceVector.x, 2) + 54 | Math.pow(distanceVector.y, 2) 55 | ); 56 | 57 | const lightness = LIGHTNESS_MIN + LIGHTNESS_RANGE * distanceToMouse / LIGHTNESS_FALLOFF; 58 | 59 | const style = { 60 | position: 'absolute', 61 | transform: `translate(${boid.position.x}px, ${boid.position.y}px) rotate(${angle}rad) scale(${scale})`, 62 | 'border-color': `transparent transparent transparent hsl(${boid.hue}, 100%, ${lightness}%)` 63 | }; 64 | 65 | return ( 66 | div('.boid', {key: boid.key, style}) 67 | ); 68 | } 69 | 70 | function view (state) { 71 | const slider = (className, {value, min, max}) => 72 | input(`.control ${className}`, {attrs: {type: 'range', value, min, max}}); 73 | 74 | return ( 75 | div('.flock', [ 76 | div('.controls', [ 77 | slider('.avoidance', state.weights.avoidance), 78 | slider('.avoidance-distance', state.weights.avoidanceDistance), 79 | slider('.mouse-position', state.weights.mousePosition), 80 | slider('.flock-centre', state.weights.flockCentre) 81 | ]), 82 | 83 | div('.boids', state.flock.map(boid => renderBoid(boid, state.mousePosition, state.delta))) 84 | ]) 85 | ); 86 | } 87 | 88 | function sign (number) { 89 | if (number < 0) { 90 | return -1; 91 | } else if (number > 0) { 92 | return 1; 93 | } 94 | 95 | return 0; 96 | } 97 | 98 | function moveTowards (boid, delta, position, speed) { 99 | const distance = { 100 | x: position.x - boid.position.x, 101 | y: position.y - boid.position.y 102 | }; 103 | 104 | const absoluteDistance = { 105 | x: Math.abs(distance.x), 106 | y: Math.abs(distance.y) 107 | }; 108 | 109 | const normalizedDistance = normalizeVector(absoluteDistance); 110 | 111 | boid.velocity.x += normalizedDistance.x * sign(distance.x) * speed * delta; 112 | boid.velocity.y += normalizedDistance.y * sign(distance.y) * speed * delta; 113 | } 114 | 115 | function normalizeVector (vector) { 116 | const vectorLength = Math.abs(vector.x + vector.y); 117 | 118 | if (vectorLength === 0) { 119 | return {x: 0, y: 0}; 120 | } 121 | 122 | return { 123 | x: vector.x / vectorLength, 124 | y: vector.y / vectorLength 125 | }; 126 | } 127 | 128 | function calculateFlockCentre (flock) { 129 | return { 130 | x: _.mean(_.map(flock, 'position.x')), 131 | y: _.mean(_.map(flock, 'position.y')) 132 | }; 133 | } 134 | 135 | function moveAwayFromCloseBoids (boid, flock, avoidance, avoidanceDistance, delta) { 136 | flock.forEach(otherBoid => { 137 | if (boid === otherBoid) { return; } 138 | 139 | const distanceVector = { 140 | x: Math.abs(boid.position.x - otherBoid.position.x), 141 | y: Math.abs(boid.position.y - otherBoid.position.y) 142 | }; 143 | 144 | const distance = Math.sqrt( 145 | Math.pow(distanceVector.x, 2) + 146 | Math.pow(distanceVector.y, 2) 147 | ); 148 | 149 | if (distance < avoidanceDistance) { 150 | moveTowards(boid, delta, otherBoid.position, -avoidance); 151 | } 152 | }); 153 | } 154 | 155 | function makeWeightUpdateReducer$ (weightPropertyName, weight$) { 156 | return weight$.map(weight => { 157 | return function (state) { 158 | state.weights[weightPropertyName].value = weight; 159 | 160 | return state; 161 | }; 162 | }); 163 | } 164 | 165 | function updateBoid (boid, delta, mousePosition, flockCentre, flock, weights) { 166 | moveTowards( 167 | boid, 168 | delta, 169 | mousePosition, 170 | weights.mousePosition.value / 100 171 | ); 172 | 173 | moveTowards( 174 | boid, 175 | delta, 176 | flockCentre, 177 | weights.flockCentre.value / 100 178 | ); 179 | 180 | moveAwayFromCloseBoids( 181 | boid, 182 | flock, 183 | weights.avoidance.value / 100, 184 | weights.avoidanceDistance.value, 185 | delta 186 | ); 187 | 188 | boid.position.x += boid.velocity.x * delta; 189 | boid.position.y += boid.velocity.y * delta; 190 | 191 | boid.velocity.x *= FRICTION / delta; 192 | boid.velocity.y *= FRICTION / delta; 193 | 194 | return boid; 195 | } 196 | 197 | function update (state, delta, mousePosition) { 198 | state.mousePosition = mousePosition; 199 | state.delta = delta; 200 | 201 | const flockCentre = calculateFlockCentre(state.flock); 202 | 203 | state.flock.forEach(boid => updateBoid( 204 | boid, 205 | delta, 206 | mousePosition, 207 | flockCentre, 208 | state.flock, 209 | state.weights 210 | )); 211 | 212 | return state; 213 | } 214 | 215 | function main ({DOM, Time, Mouse}) { 216 | const initialState = { 217 | flock: makeflock(BOID_COUNT), 218 | mousePosition: {x: 0, y: 0}, 219 | delta: 1, 220 | 221 | weights: { 222 | avoidance: {value: 110, min: 50, max: 150}, 223 | avoidanceDistance: {value: 50, min: 10, max: 100}, 224 | flockCentre: {value: 20, min: 5, max: 50}, 225 | mousePosition: {value: 50, min: 10, max: 100} 226 | } 227 | }; 228 | 229 | const avoidanceSlider$ = DOM 230 | .select('.avoidance') 231 | .events('input') 232 | .map(ev => ev.target.value); 233 | 234 | const avoidanceDistanceSlider$ = DOM 235 | .select('.avoidance-distance') 236 | .events('input') 237 | .map(ev => ev.target.value); 238 | 239 | const mousePositionSlider$ = DOM 240 | .select('.mouse-position') 241 | .events('input') 242 | .map(ev => ev.target.value); 243 | 244 | const flockCentreSlider$ = DOM 245 | .select('.flock-centre') 246 | .events('input') 247 | .map(ev => ev.target.value); 248 | 249 | const updateAvoidanceWeight$ = makeWeightUpdateReducer$('avoidance', avoidanceSlider$); 250 | const updateAvoidanceDistanceWeight$ = makeWeightUpdateReducer$('avoidanceDistance', avoidanceDistanceSlider$); 251 | const updateMousePositionWeight$ = makeWeightUpdateReducer$('mousePosition', mousePositionSlider$); 252 | const updateFlockCentreWeight$ = makeWeightUpdateReducer$('flockCentre', flockCentreSlider$); 253 | 254 | const tick$ = Time.map(time => time.delta / FRAME_RATE); 255 | 256 | const update$ = Mouse.positions() 257 | .map(mousePosition => tick$.map(delta => state => update(state, delta, mousePosition))) 258 | .flatten(); 259 | 260 | const reducer$ = xs.merge( 261 | update$, 262 | 263 | updateAvoidanceWeight$, 264 | updateAvoidanceDistanceWeight$, 265 | updateMousePositionWeight$, 266 | updateFlockCentreWeight$ 267 | ); 268 | 269 | const state$ = reducer$.fold((state, reducer) => reducer(state), initialState); 270 | 271 | return { 272 | DOM: state$.map(view) 273 | }; 274 | } 275 | 276 | const drivers = { 277 | DOM: makeDOMDriver('.app'), 278 | Time: timeDriver, 279 | Mouse: mousePositionDriver 280 | }; 281 | 282 | run(main, drivers); 283 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boids", 3 | "version": "1.0.0", 4 | "description": "Boids in Cycle.js (bird flocking simulator)", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "budo index.js:bundle.js -- -t babelify | garnish", 9 | "bundle": "browserify index.js -o bundle.js -t babelify" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/cyclejs-community/boids.git" 14 | }, 15 | "author": "Raquel Moss and Nick Johnstone", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/cyclejs-community/boids/issues" 19 | }, 20 | "homepage": "https://github.com/cyclejs-community/boids", 21 | "dependencies": { 22 | "@cycle/core": "^7.0.0-rc8", 23 | "@cycle/dom": "^10.0.0-rc34", 24 | "@cycle/xstream-run": "^3.0.2", 25 | "lodash": "^4.13.1", 26 | "node-uuid": "^1.4.7", 27 | "performance-now": "^0.2.0", 28 | "raf": "^3.2.0", 29 | "rx": "^4.1.0", 30 | "xstream": "^5.0.6" 31 | }, 32 | "devDependencies": { 33 | "babel-core": "^6.9.1", 34 | "babel-plugin-transform-object-rest-spread": "^6.8.0", 35 | "babel-preset-es2015": "^6.9.0", 36 | "babelify": "^7.3.0", 37 | "browserify": "^13.0.1", 38 | "budo": "^8.3.0", 39 | "garnish": "^5.2.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /screenshots/0qNNe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyclejs-community/boids/cd031642ebd69acc61fc11cd870a21fb869dd252/screenshots/0qNNe.png -------------------------------------------------------------------------------- /screenshots/4OkB2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyclejs-community/boids/cd031642ebd69acc61fc11cd870a21fb869dd252/screenshots/4OkB2.png -------------------------------------------------------------------------------- /screenshots/QrNxq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyclejs-community/boids/cd031642ebd69acc61fc11cd870a21fb869dd252/screenshots/QrNxq.png -------------------------------------------------------------------------------- /scripts/deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | git fetch 4 | 5 | git checkout gh-pages 6 | 7 | git merge master --no-commit 8 | 9 | npm install 10 | 11 | npm run bundle 12 | 13 | git commit -am "Update bundle" 14 | 15 | git push origin gh-pages --force 16 | 17 | git checkout - 18 | -------------------------------------------------------------------------------- /src/mouse-position-driver.js: -------------------------------------------------------------------------------- 1 | import xs from 'xstream'; 2 | 3 | function fromEvent (element, eventName) { 4 | const event$ = xs.create(); 5 | element.addEventListener(eventName, ev => event$.shamefullySendNext(ev)); 6 | return event$; 7 | } 8 | 9 | export default function mousePositionDriver () { 10 | return { 11 | positions () { 12 | return fromEvent(document, 'mousemove') 13 | .map(ev => { 14 | return {x: ev.clientX, y: ev.clientY}; 15 | }).startWith({x: window.innerWidth / 2, y: window.innerHeight / 2}); 16 | } 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/time-driver.js: -------------------------------------------------------------------------------- 1 | import requestAnimationFrame from 'raf'; 2 | import now from 'performance-now'; 3 | import xs from 'xstream'; 4 | 5 | export default function timeDriver () { 6 | const animation$ = xs.create(); 7 | 8 | let previousTime = now(); 9 | 10 | function tick (timestamp) { 11 | animation$.shamefullySendNext({ 12 | timestamp, 13 | delta: timestamp - previousTime 14 | }); 15 | 16 | previousTime = timestamp; 17 | 18 | requestAnimationFrame(tick); 19 | } 20 | 21 | tick(previousTime); 22 | 23 | return animation$; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #111; 3 | overflow: hidden; 4 | } 5 | 6 | .boid { 7 | width: 0; 8 | height: 0; 9 | border-style: solid; 10 | border-width: 12.5px 0 12.5px 30px; 11 | border-color: transparent transparent transparent #9900ff; 12 | } 13 | 14 | .controls { 15 | position: absolute; 16 | bottom: 20px; 17 | display: flex; 18 | justify-content: space-around; 19 | width: 100%; 20 | } 21 | 22 | .control { 23 | width: 15%; 24 | opacity: 0.6; 25 | } 26 | --------------------------------------------------------------------------------