├── .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 | 
8 | 
9 | 
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 |
--------------------------------------------------------------------------------