├── README.md
├── Makefile
├── LICENSE
├── index.html
├── style.css
├── client.js
└── client.coffee.md
/README.md:
--------------------------------------------------------------------------------
1 | client.coffee.md
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build watch
2 |
3 | build:
4 | coffee -c .
5 |
6 | watch:
7 | coffee -cw .
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2017 Sam Gentle
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
21 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
Hey! Just so you know, these automata rules include flashy white-to-black and black-to-white transitions, which could trigger photosensitive epilepsy.
33 |
What would you like to do?
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | width: 100%;
3 | height: 100%;
4 | margin: 0;
5 | font-size: 24px;
6 | font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
7 | }
8 | body {
9 | display: flex;
10 | flex-direction: row;
11 | }
12 | button {
13 | height: 48px;
14 | flex-shrink: 0;
15 | font-size: 16px;
16 | }
17 | .delrule {
18 | width: 36px;
19 | height: 36px;
20 | margin-left: 8px;
21 | }
22 | #main {
23 | margin: 16px;
24 | flex: 1 1 auto;
25 | display: flex;
26 | flex-direction: column;
27 | }
28 | #canvas {
29 | flex: 1 1 auto;
30 | height: 0;
31 | object-fit: contain;
32 | }
33 | #aspectcontainer {
34 | position: relative;
35 | flex: 0 0 auto;
36 | width: 100vh;
37 | height: 0;
38 | padding-bottom: 100%;
39 | overflow:hidden;
40 | }
41 | #sidebar {
42 | display: flex;
43 | flex-direction: column;
44 | flex: 0 1 auto;
45 | margin: 16px;
46 | align-items: flex-end;
47 | }
48 | #addrule {
49 | width: 100%;
50 | }
51 | #clear {
52 | width: 100%;
53 | height: 48px;
54 | }
55 | #rules {
56 | flex: 1 1 auto;
57 | flex-direction: column;
58 | overflow-y: scroll;
59 | }
60 | .rule,.temprule{
61 | display: flex;
62 | flex-direction: row;
63 | align-items: center;
64 | font-size: 48px;
65 | margin-bottom: 16px;
66 | }
67 | .temprule {
68 | opacity: 0.5;
69 | }
70 | #controls {
71 | flex: 0 0 auto;
72 | display: flex;
73 | flex-direction: row;
74 | align-items: center;
75 | justify-content: center;
76 | margin-top: 16px;
77 | }
78 | #controls > * {
79 | margin-right: 8px;
80 | vertical-align: middle;
81 | }
82 | #tools {
83 | flex: 0 0 auto;
84 | margin-top: 16px;
85 | width: 100%;
86 | }
87 | #links {
88 | position: absolute;
89 | bottom: 4px;
90 | left: 8px;
91 | font-size: 16px;
92 | background-color: white;
93 | }
94 | a {
95 | color: black;
96 | }
97 | #epilepsy-warning {
98 | display: none;
99 | position: absolute;
100 | background-color: white;
101 | border-radius: 32px;
102 | box-shadow: 8px 8px 32px rgba(0, 0, 0, 0.5);
103 | padding: 1em 2em;
104 | flex-direction: column;
105 | top: 20%;
106 | left: 10%;
107 | bottom: 20%;
108 | right: 10%;
109 | }
110 | @media screen and (max-width: 650px), screen and (max-height: 800px) {
111 | #epilepsy-warning {
112 | top: 0;
113 | left: 0;
114 | bottom: 0;
115 | right: 0;
116 | border-radius: 0px;
117 | }
118 | }
119 | #epilepsy-warning div {
120 | flex: 1 1 auto;
121 | margin: 1em 0;
122 | }
123 | #epilepsy-options {
124 | text-align: center;
125 | }
126 |
--------------------------------------------------------------------------------
/client.js:
--------------------------------------------------------------------------------
1 | // Generated by CoffeeScript 1.12.5
2 | (function() {
3 | var $, DATA_H, DATA_LENGTH, DATA_W, addButton, canvas, clearTempRules, click, clicking, ctx, data, draw, drawcount, drawdata, drawmode, epilepsyWarning, getActualBoundingBox, getNeighbours, handleEpilepsy, lasti, locationTimer, makeNeighbourImage, makeRuleEl, olddata, paused, popcount, raf, ruleContainer, rules, rulesFromQuery, setEpilepsyHandler, setNeighbours, setPause, setup, step, timeScale, updateLocation, updateRules, updateSpeed;
4 |
5 | $ = document.querySelector.bind(document);
6 |
7 | canvas = $('#canvas');
8 |
9 | ctx = canvas.getContext("2d");
10 |
11 | ctx.fillRect(0, 0, canvas.width, canvas.height);
12 |
13 | DATA_W = 100;
14 |
15 | DATA_H = 100;
16 |
17 | DATA_LENGTH = DATA_W * DATA_H;
18 |
19 | data = new Uint8Array(DATA_LENGTH);
20 |
21 | olddata = new Uint8Array(DATA_LENGTH);
22 |
23 | drawdata = new Uint8ClampedArray(DATA_LENGTH * 4);
24 |
25 | data[Math.floor(DATA_LENGTH / 2) - Math.floor(DATA_W / 2)] = 1;
26 |
27 | olddata[Math.floor(DATA_LENGTH / 2) - Math.floor(DATA_W / 2)] = 1;
28 |
29 | setup = function() {
30 | var j, len, ref, results, x;
31 | ctx.setTransform(canvas.width / DATA_W, 0, 0, canvas.height / DATA_H, 0, 0);
32 | ctx.globalCompositeOperation = "copy";
33 | ctx.imageSmoothingEnabled = false;
34 | ref = 'moz ms webkit'.split(' ');
35 | results = [];
36 | for (j = 0, len = ref.length; j < len; j++) {
37 | x = ref[j];
38 | results.push(ctx[x + "ImageSmoothingEnabled"] = false);
39 | }
40 | return results;
41 | };
42 |
43 | timeScale = 10;
44 |
45 | drawcount = 0;
46 |
47 | draw = function() {
48 | var drawR, i, imageData, j, n, ref, v;
49 | drawR = drawcount / timeScale;
50 | for (i = j = 0, ref = DATA_LENGTH; j < ref; i = j += 1) {
51 | v = data[i] * drawR + olddata[i] * (1 - drawR);
52 | n = i * 4;
53 | drawdata[n] = drawdata[n + 1] = drawdata[n + 2] = v * 255;
54 | drawdata[n + 3] = 255;
55 | }
56 | imageData = new ImageData(drawdata, DATA_W, DATA_H);
57 | ctx.putImageData(imageData, 0, 0);
58 | return ctx.drawImage(ctx.canvas, 0, 0);
59 | };
60 |
61 | paused = false;
62 |
63 | raf = function(t) {
64 | if (!paused) {
65 | drawcount++;
66 | }
67 | draw();
68 | if (drawcount > timeScale) {
69 | drawcount = 0;
70 | step();
71 | }
72 | return requestAnimationFrame(raf);
73 | };
74 |
75 | setup();
76 |
77 | raf();
78 |
79 | rules = new Array(1 << 9);
80 |
81 | step = function() {
82 | var i, j, k, n, ref, ref1, results, v;
83 | for (i = j = 0, ref = DATA_LENGTH; j < ref; i = j += 1) {
84 | olddata[i] = data[i];
85 | }
86 | results = [];
87 | for (i = k = 0, ref1 = DATA_LENGTH; k < ref1; i = k += 1) {
88 | if (v = rules[getNeighbours(olddata, i)]) {
89 | n = getNeighbours(data, i);
90 | results.push(setNeighbours(data, i, n ^ v));
91 | } else {
92 | results.push(void 0);
93 | }
94 | }
95 | return results;
96 | };
97 |
98 | getNeighbours = function(data, i) {
99 | var bot, l, mid, r, top;
100 | mid = (i + DATA_LENGTH) % DATA_LENGTH;
101 | top = (i - DATA_W + DATA_LENGTH) % DATA_LENGTH;
102 | bot = (i + DATA_W) % DATA_LENGTH;
103 | r = i % DATA_W === (DATA_W - 1) ? 1 - DATA_W : 1;
104 | l = i % DATA_W === 0 ? DATA_W - 1 : -1;
105 | return (data[top + l] << 0) + (data[top + 0] << 1) + (data[top + r] << 2) + (data[mid + l] << 3) + (data[mid + 0] << 4) + (data[mid + r] << 5) + (data[bot + l] << 6) + (data[bot + 0] << 7) + (data[bot + r] << 8);
106 | };
107 |
108 | setNeighbours = function(data, i, n) {
109 | var bot, l, mid, r, top;
110 | mid = (i + DATA_LENGTH) % DATA_LENGTH;
111 | top = (i - DATA_W + DATA_LENGTH) % DATA_LENGTH;
112 | bot = (i + DATA_W) % DATA_LENGTH;
113 | r = i % DATA_W === (DATA_W - 1) ? 1 - DATA_W : 1;
114 | l = i % DATA_W === 0 ? DATA_W - 1 : -1;
115 | data[top + l] = (n & 1 << 0) >> 0;
116 | data[top + 0] = (n & 1 << 1) >> 1;
117 | data[top + r] = (n & 1 << 2) >> 2;
118 | data[mid + l] = (n & 1 << 3) >> 3;
119 | data[mid + 0] = (n & 1 << 4) >> 4;
120 | data[mid + r] = (n & 1 << 5) >> 5;
121 | data[bot + l] = (n & 1 << 6) >> 6;
122 | data[bot + 0] = (n & 1 << 7) >> 7;
123 | return data[bot + r] = (n & 1 << 8) >> 8;
124 | };
125 |
126 | ruleContainer = $('#rules');
127 |
128 | addButton = $('#addrule');
129 |
130 | makeRuleEl = function(pattern, modifier) {
131 | var div, id;
132 | id = "rule-" + pattern;
133 | div = document.createElement('div');
134 | div.className = pattern != null ? 'rule' : 'temprule';
135 | div.innerHTML = "\n➡\n\n";
136 | if (pattern != null) {
137 | div.id = id;
138 | }
139 | div.setAttribute('data-pattern', pattern || 0);
140 | div.setAttribute('data-modifier', modifier || 0);
141 | return div;
142 | };
143 |
144 | makeNeighbourImage = function(n, s) {
145 | var cs, i, j, k, tmpcanvas, tmpctx, x, y;
146 | if (s == null) {
147 | s = 100;
148 | }
149 | tmpcanvas = document.createElement('canvas');
150 | tmpcanvas.width = tmpcanvas.height = s;
151 | tmpctx = tmpcanvas.getContext('2d');
152 | cs = s / 3;
153 | for (i = j = 0; j <= 8; i = ++j) {
154 | x = i % 3 * cs;
155 | y = Math.floor(i / 3) * cs;
156 | tmpctx.fillStyle = n & 1 << i ? '#fff' : '#000';
157 | tmpctx.fillRect(x, y, cs, cs);
158 | }
159 | tmpctx.fillStyle = 'rgb(127,127,127)';
160 | for (i = k = 0; k <= 3; i = ++k) {
161 | tmpctx.fillRect(cs * i, 0, 1, s);
162 | tmpctx.fillRect(0, cs * i, s, 1);
163 | }
164 | return tmpcanvas.toDataURL();
165 | };
166 |
167 | clearTempRules = function() {
168 | var j, len, ref, results, x;
169 | ref = ruleContainer.querySelectorAll('.temprule');
170 | results = [];
171 | for (j = 0, len = ref.length; j < len; j++) {
172 | x = ref[j];
173 | results.push(x.remove());
174 | }
175 | return results;
176 | };
177 |
178 | updateRules = function() {
179 | var _, e, j, len, ref, results, stale;
180 | if (!rules.some(function() {
181 | return true;
182 | })) {
183 | rules[16] = 0;
184 | }
185 | stale = {};
186 | ref = ruleContainer.querySelectorAll('.rule');
187 | for (j = 0, len = ref.length; j < len; j++) {
188 | e = ref[j];
189 | stale[e.id] = e;
190 | }
191 | rules.forEach(function(modifier, pattern) {
192 | var existingEl, id;
193 | id = "rule-" + pattern;
194 | delete stale[id];
195 | existingEl = document.getElementById(id);
196 | if (existingEl) {
197 | if (+existingEl.getAttribute('data-modifier') !== modifier) {
198 | return ruleContainer.replaceChild(makeRuleEl(pattern, modifier), existingEl);
199 | }
200 | } else {
201 | return ruleContainer.insertBefore(makeRuleEl(pattern, modifier), addButton);
202 | }
203 | });
204 | updateLocation();
205 | if (rules[0] && rules[(1 << 9) - 1]) {
206 | epilepsyWarning();
207 | }
208 | results = [];
209 | for (_ in stale) {
210 | e = stale[_];
211 | results.push(e.remove());
212 | }
213 | return results;
214 | };
215 |
216 | ruleContainer.addEventListener('click', function(ev) {
217 | var i, kind, modifier, newpattern, newrule, pattern, replacedEl, temprule, v, x, y;
218 | kind = ev.target.className;
219 | if (kind !== 'pattern' && kind !== 'modifier' && kind !== 'delrule') {
220 | return;
221 | }
222 | pattern = +ev.target.parentNode.getAttribute('data-pattern');
223 | modifier = +ev.target.parentNode.getAttribute('data-modifier');
224 | x = Math.floor(ev.offsetX / ev.target.offsetWidth * 3);
225 | y = Math.floor(ev.offsetY / ev.target.offsetHeight * 3);
226 | i = y * 3 + x;
227 | v = 1 << i;
228 | temprule = ev.target.parentNode.className === 'temprule';
229 | if (kind === 'delrule') {
230 | ev.target.parentNode.remove();
231 | if (!temprule) {
232 | delete rules[pattern];
233 | }
234 | updateRules();
235 | return;
236 | }
237 | if (kind === 'modifier') {
238 | newrule = modifier ^ v;
239 | newpattern = pattern;
240 | } else if (kind === 'pattern') {
241 | newrule = modifier;
242 | newpattern = pattern ^ v;
243 | }
244 | replacedEl = document.getElementById("rule-" + newpattern);
245 | if (replacedEl) {
246 | replacedEl.removeAttribute('id');
247 | replacedEl.className = 'temprule';
248 | }
249 | ruleContainer.replaceChild(makeRuleEl(newpattern, newrule), ev.target.parentNode);
250 | if (!temprule) {
251 | delete rules[pattern];
252 | }
253 | rules[newpattern] = newrule;
254 | return updateRules();
255 | });
256 |
257 | locationTimer = null;
258 |
259 | updateLocation = function() {
260 | clearTimeout(locationTimer);
261 | return locationTimer = setTimeout(function() {
262 | var query;
263 | query = [];
264 | rules.forEach(function(modifier, pattern) {
265 | if (modifier !== 0) {
266 | return query.push((pattern.toString(16)) + "=" + (modifier.toString(16)));
267 | }
268 | });
269 | return history.replaceState(null, null, '?' + query.join('&'));
270 | }, 500);
271 | };
272 |
273 | $('#addrule').addEventListener('click', function() {
274 | return ruleContainer.insertBefore(makeRuleEl(), addButton);
275 | });
276 |
277 | clicking = false;
278 |
279 | drawmode = false;
280 |
281 | lasti = null;
282 |
283 | getActualBoundingBox = function(ev) {
284 | var canvasRatio, containerRatio, height, left, top, width;
285 | canvasRatio = canvas.width / canvas.height;
286 | containerRatio = canvas.offsetWidth / canvas.offsetHeight;
287 | if (containerRatio > canvasRatio) {
288 | height = canvas.offsetHeight;
289 | width = canvas.offsetHeight / canvasRatio;
290 | } else {
291 | height = canvas.offsetWidth * canvasRatio;
292 | width = canvas.offsetWidth;
293 | }
294 | left = (canvas.offsetWidth - width) / 2;
295 | top = (canvas.offsetHeight - height) / 2;
296 | return {
297 | top: top,
298 | left: left,
299 | width: width,
300 | height: height
301 | };
302 | };
303 |
304 | click = function(ev) {
305 | var bb, i, n, v, x, y;
306 | if (!clicking) {
307 | return;
308 | }
309 | bb = getActualBoundingBox();
310 | x = Math.floor((ev.offsetX - bb.left) / bb.width * DATA_W);
311 | y = Math.floor((ev.offsetY - bb.top) / bb.height * DATA_H);
312 | if (x >= DATA_W || y >= DATA_H || x < 0 || y < 0) {
313 | return;
314 | }
315 | i = y * DATA_W + x;
316 | if (drawmode) {
317 | if (i !== lasti) {
318 | data[i] = 1 - data[i];
319 | }
320 | return lasti = i;
321 | } else {
322 | n = getNeighbours(data, i);
323 | v = 1 << 4;
324 | rules[n] ^= v;
325 | return updateRules();
326 | }
327 | };
328 |
329 | canvas.addEventListener('mousedown', function(ev) {
330 | ev.preventDefault();
331 | lasti = null;
332 | if (ev.button === 0) {
333 | clicking = true;
334 | }
335 | return click(ev);
336 | });
337 |
338 | canvas.addEventListener('mouseup', function() {
339 | return clicking = false;
340 | });
341 |
342 | canvas.addEventListener('mouseout', function() {
343 | return clicking = false;
344 | });
345 |
346 | canvas.addEventListener('mousemove', function(ev) {
347 | if (drawmode) {
348 | return click(ev);
349 | }
350 | });
351 |
352 | $('#rulemode').addEventListener('click', function() {
353 | return drawmode = false;
354 | });
355 |
356 | $('#drawmode').addEventListener('click', function() {
357 | return drawmode = true;
358 | });
359 |
360 | $('#reset').addEventListener('click', function() {
361 | var i, j, ref;
362 | for (i = j = 0, ref = data.length; 0 <= ref ? j < ref : j > ref; i = 0 <= ref ? ++j : --j) {
363 | data[i] = 0;
364 | }
365 | return data[Math.floor(DATA_LENGTH / 2) - Math.floor(DATA_W / 2)] = 1;
366 | });
367 |
368 | $('#random').addEventListener('click', function() {
369 | var i, j, ref, results;
370 | results = [];
371 | for (i = j = 0, ref = data.length; 0 <= ref ? j < ref : j > ref; i = 0 <= ref ? ++j : --j) {
372 | results.push(data[i] = Math.round(Math.random()));
373 | }
374 | return results;
375 | });
376 |
377 | $('#clear').addEventListener('click', function() {
378 | rules = new Array(Math.pow(2, 9));
379 | updateRules();
380 | return clearTempRules();
381 | });
382 |
383 | setPause = function(pause) {
384 | paused = pause;
385 | drawcount = timeScale;
386 | return $('#pause').textContent = paused ? "resume" : "pause";
387 | };
388 |
389 | $('#pause').addEventListener('click', function() {
390 | return setPause(!paused);
391 | });
392 |
393 | updateSpeed = function(speed) {
394 | timeScale = 61 - speed;
395 | return $('#speed').value = speed;
396 | };
397 |
398 | $('#speed').addEventListener('input', function(ev) {
399 | return updateSpeed(ev.target.value);
400 | });
401 |
402 | epilepsyWarning = function() {
403 | var handler;
404 | if (handler = localStorage.getItem('epilepsyHandler')) {
405 | return handleEpilepsy(handler);
406 | } else {
407 | setPause(true);
408 | return $('#epilepsy-warning').style.display = 'flex';
409 | }
410 | };
411 |
412 | handleEpilepsy = function(behaviour) {
413 | switch (behaviour) {
414 | case 'pause':
415 | return setPause(true);
416 | case 'slow':
417 | return updateSpeed(1);
418 | case 'ignore':
419 | return null;
420 | }
421 | };
422 |
423 | setEpilepsyHandler = function(behaviour) {
424 | return function() {
425 | setPause(false);
426 | handleEpilepsy(behaviour);
427 | if ($('#epilepsy-persist').checked) {
428 | localStorage.setItem('epilepsyHandler', behaviour);
429 | }
430 | return $('#epilepsy-warning').style.display = 'none';
431 | };
432 | };
433 |
434 | $('#epilepsy-pause').addEventListener('click', setEpilepsyHandler('pause'));
435 |
436 | $('#epilepsy-slow').addEventListener('click', setEpilepsyHandler('slow'));
437 |
438 | $('#epilepsy-ignore').addEventListener('click', setEpilepsyHandler('ignore'));
439 |
440 | rulesFromQuery = function() {
441 | var count, i, j, k, len, live, ref, ref1, ref2, urlmodifier, urlpattern, urlrule, urlrules;
442 | if (urlrules = document.location.search.slice(1)) {
443 | if (urlrules === 'conway') {
444 | for (i = j = 0, ref = rules.length; 0 <= ref ? j < ref : j > ref; i = 0 <= ref ? ++j : --j) {
445 | live = i & (1 << 4);
446 | count = popcount(i);
447 | if ((live && (count > 4 || count < 3)) || (!live && count === 3)) {
448 | rules[i] = 1 << 4;
449 | }
450 | }
451 | } else {
452 | ref1 = urlrules.split('&');
453 | for (k = 0, len = ref1.length; k < len; k++) {
454 | urlrule = ref1[k];
455 | ref2 = urlrule.split('='), urlpattern = ref2[0], urlmodifier = ref2[1];
456 | rules[parseInt(urlpattern, 16)] = parseInt(urlmodifier, 16);
457 | }
458 | }
459 | }
460 | return updateRules();
461 | };
462 |
463 | popcount = function(i) {
464 | i = i - ((i >> 1) & 0x55555555);
465 | i = (i & 0x33333333) + ((i >> 2) & 0x33333333);
466 | return (((i + (i >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24;
467 | };
468 |
469 | rulesFromQuery();
470 |
471 | }).call(this);
472 |
--------------------------------------------------------------------------------
/client.coffee.md:
--------------------------------------------------------------------------------
1 | Automata by Example
2 | ===================
3 |
4 | This is a demonstration of two awesome things, cellular automata and rule
5 | generation. Using the two together, we can build all sorts of interesting
6 | automata by just clicking around and experimenting.
7 |
8 | The way it works is that each rule has a pattern and a modifier, both in a 3x3
9 | grid. If the pattern matches any cell, we toggle any cells that are set in the
10 | modifier. Like so:
11 |
12 | ```
13 | (pattern) (modifier) (result)
14 | ... ... ...
15 | .x. + ..x -> .xx
16 | ... ... ...
17 | ```
18 |
19 | In other words, this rule adds a cell to the right of any cell with no
20 | neighbours.
21 |
22 | Because these rules map closely to "if-then" type conditions, we can
23 | optimistically generate them as you click. The above rule could be generated
24 | by clicking one cell to the right of an existing cell with no neighbours.
25 |
26 | Clicking always generates a rule centred on the current mouse position, but
27 | there are many rules that can't be generated this way. So we also have a rule
28 | editor for more methodical rule entry.
29 |
30 |
31 | Utils and setup
32 | ---------------
33 |
34 | Dollar store jQuery + the setup for our canvas
35 |
36 | $ = document.querySelector.bind(document)
37 | canvas = $('#canvas')
38 | ctx = canvas.getContext("2d")
39 | ctx.fillRect(0, 0, canvas.width, canvas.height)
40 |
41 | Set up our data. We really only use the canvas as a pixel grid, so most of the
42 | work we'll be doing is with these arrays. We need `data` and `olddata` so that
43 | our automata appear to run instantaneously, and for nice lerping between
44 | generations.
45 |
46 | DATA_W = 100
47 | DATA_H = 100
48 | DATA_LENGTH = DATA_W*DATA_H
49 |
50 | data = new Uint8Array(DATA_LENGTH)
51 | olddata = new Uint8Array(DATA_LENGTH)
52 | drawdata = new Uint8ClampedArray(DATA_LENGTH*4) #RGBA
53 |
54 | We set the middle pixel on because otherwise it's very hard to make anything
55 | interesting happen.
56 |
57 | data[DATA_LENGTH // 2 - DATA_W // 2] = 1
58 | olddata[DATA_LENGTH // 2 - DATA_W // 2] = 1
59 |
60 |
61 | Drawing
62 | -------
63 |
64 | Because we only want pixels, the easiest way to do that is by just drawing the
65 | pixels at their native size and then scaling them up by setting a global
66 | transform. We try really hard to avoid smoothing.
67 |
68 | setup = ->
69 | ctx.setTransform(canvas.width/DATA_W, 0, 0, canvas.height/DATA_H, 0, 0)
70 | ctx.globalCompositeOperation = "copy"
71 | ctx.imageSmoothingEnabled = false
72 | ctx[x+"ImageSmoothingEnabled"] = false for x in 'moz ms webkit'.split(' ')
73 |
74 | We don't want to step the automata every frame, so `drawcount` tells us how
75 | many times we've drawn since the last step. Combined with `timeScale` we can
76 | use that to lerp between steps.
77 |
78 | timeScale = 10
79 | drawcount = 0
80 | draw = ->
81 | drawR = drawcount/timeScale
82 | for i in [0...DATA_LENGTH] by 1
83 | v = (data[i]*drawR + olddata[i]*(1-drawR))
84 | n = i*4
85 | drawdata[n] = drawdata[n+1] = drawdata[n+2] = v*255
86 | drawdata[n+3] = 255
87 |
88 | Since imageData ignores transform, we load the imageData into the canvas, then
89 | draw the canvas onto itself.
90 |
91 | imageData = new ImageData(drawdata, DATA_W, DATA_H)
92 | ctx.putImageData(imageData, 0, 0)
93 | ctx.drawImage(ctx.canvas, 0, 0)
94 |
95 | Ye olde rAF loop
96 |
97 | paused = false
98 | raf = (t) ->
99 | drawcount++ unless paused
100 | draw()
101 | if drawcount > timeScale
102 | drawcount = 0
103 | step()
104 |
105 | requestAnimationFrame raf
106 |
107 | setup()
108 | raf()
109 |
110 |
111 | Automata engine
112 | ---------------
113 |
114 | This is where we get to do some fun stuff! Since the 3x3 boolean grid is
115 | basically a 9-bit number, we can just use numbers internally.
116 |
117 | The rules are stored as a 2**9-element array. Each step, we get every cell's
118 | 3x3-equivalent number and look it up in the rule array. If it matches, we xor
119 | that number with the rule's modifier and write it back to the cell.
120 |
121 | The inputs don't overlap, ie olddata[n] can't affect olddata[n+1], but the
122 | outputs can. Rules matching two adjacent input cells can both modify the same
123 | output cells. When this happens, they xor together.
124 |
125 | rules = new Array(1<<9)
126 | step = ->
127 | for i in [0...DATA_LENGTH] by 1
128 | olddata[i] = data[i]
129 |
130 | for i in [0...DATA_LENGTH] by 1
131 | if v = rules[getNeighbours olddata, i]
132 | n = getNeighbours data, i
133 | setNeighbours data, i, (n ^ v)
134 |
135 | The getNeighbours and setNeighbours functions convert between grid
136 | representation and number representation of cells. I don't know if it was
137 | strictly necessary to unroll them, but it looks way cooler and more hackery
138 | this way.
139 |
140 | getNeighbours = (data, i) ->
141 | mid = (i + DATA_LENGTH) % DATA_LENGTH
142 | top = (i - DATA_W + DATA_LENGTH) % DATA_LENGTH
143 | bot = (i + DATA_W) % DATA_LENGTH
144 | r = if i % DATA_W is (DATA_W-1) then 1-DATA_W else 1
145 | l = if i % DATA_W is 0 then DATA_W-1 else -1
146 |
147 | (data[(top+l)] << 0) +
148 | (data[(top+0)] << 1) +
149 | (data[(top+r)] << 2) +
150 | (data[(mid+l)] << 3) +
151 | (data[(mid+0)] << 4) +
152 | (data[(mid+r)] << 5) +
153 | (data[(bot+l)] << 6) +
154 | (data[(bot+0)] << 7) +
155 | (data[(bot+r)] << 8)
156 |
157 | setNeighbours = (data, i, n) ->
158 | mid = (i + DATA_LENGTH) % DATA_LENGTH
159 | top = (i - DATA_W + DATA_LENGTH) % DATA_LENGTH
160 | bot = (i + DATA_W) % DATA_LENGTH
161 | r = if i % DATA_W is (DATA_W-1) then 1-DATA_W else 1
162 | l = if i % DATA_W is 0 then DATA_W-1 else -1
163 |
164 | data[(top+l)] = (n & 1 << 0) >> 0
165 | data[(top+0)] = (n & 1 << 1) >> 1
166 | data[(top+r)] = (n & 1 << 2) >> 2
167 | data[(mid+l)] = (n & 1 << 3) >> 3
168 | data[(mid+0)] = (n & 1 << 4) >> 4
169 | data[(mid+r)] = (n & 1 << 5) >> 5
170 | data[(bot+l)] = (n & 1 << 6) >> 6
171 | data[(bot+0)] = (n & 1 << 7) >> 7
172 | data[(bot+r)] = (n & 1 << 8) >> 8
173 |
174 |
175 | Rules editor
176 | ------------
177 |
178 | The golden ratio of web development:
179 |
180 | Let C = time taken to write the actual code
181 | Let U = time getting the UI to work
182 | Let P = time fighting one obscure CSS problem
183 | Let M = time making it work on Mobile Safari
184 |
185 | Then C == D == P == M
186 |
187 | Here we have the rule editor in the sidebar. Each rule is represented as a bit
188 | of HTML that we generate like filthy jQuery peasants. To make up for it, we
189 | generate the rule images using Canvas and Data URIs so that I can still hang
190 | out with the cool developers.
191 |
192 | ruleContainer = $('#rules')
193 | addButton = $('#addrule')
194 |
195 | makeRuleEl = (pattern, modifier) ->
196 | id = "rule-#{pattern}"
197 | div = document.createElement 'div'
198 | div.className = if pattern? then 'rule' else 'temprule'
199 | div.innerHTML = """
200 |
201 | ➡
202 |
203 |
204 | """
205 | div.id = id if pattern?
206 | div.setAttribute 'data-pattern', pattern or 0
207 | div.setAttribute 'data-modifier', modifier or 0
208 | div
209 |
210 | makeNeighbourImage = (n, s=100) ->
211 | tmpcanvas = document.createElement 'canvas'
212 | tmpcanvas.width = tmpcanvas.height = s
213 | tmpctx = tmpcanvas.getContext '2d'
214 | cs = s / 3
215 | for i in [0..8]
216 | x = i % 3 * cs
217 | y = i // 3 * cs
218 | tmpctx.fillStyle = if (n & 1 << i) then '#fff' else '#000'
219 | tmpctx.fillRect x, y, cs, cs
220 |
221 | tmpctx.fillStyle = 'rgb(127,127,127)'
222 | for i in [0..3]
223 | tmpctx.fillRect cs*i, 0, 1, s
224 | tmpctx.fillRect 0, cs*i, s, 1
225 |
226 | tmpcanvas.toDataURL()
227 |
228 | clearTempRules = ->
229 | x.remove() for x in ruleContainer.querySelectorAll('.temprule')
230 |
231 | When we want to update the rules, we do a bit of dollar store virtual DOM. We
232 | add any rules that aren't in the list, modify ones that are but have had their
233 | modifiers changed, do nothing with the ones that haven't changed, and delete
234 | any left over.
235 |
236 | This code was originally nicer because it represented the rules UI as a pure
237 | function of the actual rules list. The problem with that is then the rules are
238 | in a non-intuitive order and, worse, if you change the pattern the rule jumps
239 | around.
240 |
241 | updateRules = ->
242 | rules[16] = 0 if !rules.some(-> true)
243 |
244 | stale = {}
245 | stale[e.id] = e for e in ruleContainer.querySelectorAll('.rule')
246 |
247 | rules.forEach (modifier, pattern) ->
248 | id = "rule-#{pattern}"
249 | delete stale[id]
250 |
251 | existingEl = document.getElementById id
252 | if existingEl
253 | if +existingEl.getAttribute('data-modifier') != modifier
254 | ruleContainer.replaceChild makeRuleEl(pattern, modifier), existingEl
255 | else
256 | ruleContainer.insertBefore makeRuleEl(pattern, modifier), addButton
257 |
258 | updateLocation()
259 |
260 | epilepsyWarning() if rules[0] and rules[(1<<9)-1]
261 |
262 | e.remove() for _, e of stale
263 |
264 | Finally, our monster onclick handler. This deals with any updates to the rules
265 | via the side panel, separation of concerns be damned.
266 |
267 | We have two kinds of entries, regular rules and temp rules (the greyed out
268 | ones). Temp rules are rule entries in the list that aren't backed by an actual
269 | rule. We do this when you click the 'add rule' button and when you would
270 | otherwise clobber an existing rule.
271 |
272 | ruleContainer.addEventListener 'click', (ev) ->
273 | kind = ev.target.className
274 | return unless kind in ['pattern', 'modifier', 'delrule']
275 | pattern = +ev.target.parentNode.getAttribute('data-pattern')
276 | modifier = +ev.target.parentNode.getAttribute('data-modifier')
277 | x = Math.floor(ev.offsetX / ev.target.offsetWidth * 3)
278 | y = Math.floor(ev.offsetY / ev.target.offsetHeight * 3)
279 | i = y * 3 + x
280 | v = (1 << i)
281 | temprule = ev.target.parentNode.className is 'temprule'
282 |
283 | if kind is 'delrule'
284 | ev.target.parentNode.remove()
285 | delete rules[pattern] unless temprule
286 | updateRules()
287 | return
288 |
289 | if kind is 'modifier'
290 | newrule = modifier ^ v
291 | newpattern = pattern
292 | else if kind is 'pattern'
293 | newrule = modifier
294 | newpattern = pattern ^ v
295 |
296 | replacedEl = document.getElementById "rule-#{newpattern}"
297 | if replacedEl
298 | replacedEl.removeAttribute 'id'
299 | replacedEl.className = 'temprule'
300 |
301 | ruleContainer.replaceChild makeRuleEl(newpattern, newrule), ev.target.parentNode
302 |
303 | delete rules[pattern] unless temprule
304 | rules[newpattern] = newrule
305 |
306 | updateRules()
307 |
308 | We also update the location to reflect the current rules, so the URL can be
309 | shared around when you find something cool. That's right. We do social.
310 |
311 | locationTimer = null
312 | updateLocation = ->
313 | clearTimeout locationTimer
314 | locationTimer = setTimeout ->
315 | query = []
316 | rules.forEach (modifier, pattern) ->
317 | query.push "#{pattern.toString(16)}=#{modifier.toString(16)}" unless modifier is 0
318 | history.replaceState null, null, '?' + query.join '&'
319 | , 500
320 |
321 |
322 |
323 | $('#addrule').addEventListener 'click', ->
324 | ruleContainer.insertBefore makeRuleEl(), addButton
325 |
326 |
327 | Drawing tool
328 | ------------
329 |
330 | This is the code that handles rule generation by clicking. We have two modes,
331 | rule mode and draw mode. Draw mode toggles the cell under your mouse when you
332 | click. Rule mode instead creates the rule that would toggle the cell under
333 | your mouse, and any others like it.
334 |
335 | clicking = false
336 | drawmode = false
337 | lasti = null
338 |
339 | To preserve the aspect ratio of our canvas when scaling, we're using CSS
340 | `object-fit`, which is basically the WHATWG's version of a "kick me" sign.
341 | There's no way to find out what actual coordinates were clicked on, so we
342 | reimplement the algorithm ourselves to figure out the coordinates.
343 |
344 | getActualBoundingBox = (ev) ->
345 | canvasRatio = canvas.width / canvas.height
346 | containerRatio = canvas.offsetWidth/canvas.offsetHeight
347 |
348 | if containerRatio > canvasRatio
349 | height = canvas.offsetHeight
350 | width = canvas.offsetHeight / canvasRatio
351 | else
352 | height = canvas.offsetWidth * canvasRatio
353 | width = canvas.offsetWidth
354 |
355 | left = (canvas.offsetWidth - width) / 2
356 | top = (canvas.offsetHeight - height) / 2
357 |
358 | {top, left, width, height}
359 |
360 | With that out of the way, here's our click and/or drag handler for actually
361 | setting the rules or pixels when we click and/or drag on them.
362 |
363 | click = (ev) ->
364 | return unless clicking
365 |
366 | bb = getActualBoundingBox()
367 |
368 | x = Math.floor (ev.offsetX - bb.left) / bb.width * DATA_W
369 | y = Math.floor (ev.offsetY - bb.top) / bb.height * DATA_H
370 | return if x >= DATA_W or y >= DATA_H or x < 0 or y < 0
371 |
372 | i = y * DATA_W + x
373 | if drawmode
374 | data[i] = 1-data[i] unless i is lasti
375 | lasti = i
376 | else #rule mode
377 | n = getNeighbours data, i
378 | v = (1 << 4) #Middle pixel
379 | rules[n] ^= v
380 | updateRules()
381 |
382 | canvas.addEventListener 'mousedown', (ev) ->
383 | ev.preventDefault()
384 | lasti = null
385 | clicking = true if ev.button is 0
386 | click(ev)
387 |
388 | canvas.addEventListener 'mouseup', -> clicking = false
389 | canvas.addEventListener 'mouseout', -> clicking = false
390 | canvas.addEventListener 'mousemove', (ev) -> click(ev) if drawmode
391 |
392 |
393 | Buttons!
394 | --------
395 |
396 | Here's where we set the listeners for our various toggles, sliders and buttons.
397 |
398 |
399 | $('#rulemode').addEventListener 'click', -> drawmode = false
400 | $('#drawmode').addEventListener 'click', -> drawmode = true
401 |
402 | $('#reset').addEventListener 'click', ->
403 | for i in [0...data.length]
404 | data[i] = 0
405 | data[DATA_LENGTH // 2 - DATA_W // 2] = 1
406 |
407 | $('#random').addEventListener 'click', ->
408 | for i in [0...data.length]
409 | data[i] = Math.round(Math.random())
410 |
411 | $('#clear').addEventListener 'click', ->
412 | rules = new Array(2**9)
413 | updateRules()
414 | clearTempRules()
415 |
416 | setPause = (pause) ->
417 | paused = pause
418 | drawcount = timeScale
419 | $('#pause').textContent = if paused then "resume" else "pause"
420 |
421 | $('#pause').addEventListener 'click', -> setPause !paused
422 |
423 | updateSpeed = (speed) ->
424 | timeScale = 61 - speed
425 | $('#speed').value = speed
426 |
427 | $('#speed').addEventListener 'input', (ev) -> updateSpeed ev.target.value
428 |
429 | Epilepsy warning
430 | ----------------
431 |
432 | Rules for 0x0 and 0x1ff (full white + full black) can lead to some interesting
433 | patterns, but also possibly trigger photosensitive epilepsy. If we have those
434 | rules display a warning and give the option to play at a lower speed or pause.
435 |
436 | epilepsyWarning = ->
437 | if handler = localStorage.getItem 'epilepsyHandler'
438 | handleEpilepsy handler
439 | else
440 | setPause true
441 | $('#epilepsy-warning').style.display = 'flex'
442 |
443 | handleEpilepsy = (behaviour) ->
444 | switch behaviour
445 | when 'pause' then setPause true
446 | when 'slow' then updateSpeed 1
447 | when 'ignore' then null
448 |
449 | setEpilepsyHandler = (behaviour) -> ->
450 | setPause false
451 | handleEpilepsy behaviour
452 | if $('#epilepsy-persist').checked
453 | localStorage.setItem 'epilepsyHandler', behaviour
454 | $('#epilepsy-warning').style.display = 'none'
455 |
456 | $('#epilepsy-pause').addEventListener 'click', setEpilepsyHandler 'pause'
457 | $('#epilepsy-slow').addEventListener 'click', setEpilepsyHandler 'slow'
458 | $('#epilepsy-ignore').addEventListener 'click', setEpilepsyHandler 'ignore'
459 |
460 |
461 | URL parsing
462 | -----------
463 |
464 | Finally, we set the rules if we have a query string. Thanks for reading,
465 | intrepid code explorer! Since you made it all this way, there's a special
466 | easter egg for you in this function.
467 |
468 | rulesFromQuery = ->
469 | if urlrules = document.location.search.slice(1)
470 | if urlrules == 'conway'
471 | for i in [0...rules.length]
472 | live = (i & (1<<4))
473 | count = popcount(i)
474 | if (live and (count > 4 or count < 3)) or (!live and count == 3)
475 | rules[i] = (1<<4)
476 |
477 | else
478 | for urlrule in urlrules.split '&'
479 | [urlpattern, urlmodifier] = urlrule.split '='
480 | rules[parseInt(urlpattern, 16)] = parseInt(urlmodifier, 16)
481 |
482 | updateRules()
483 |
484 | Thanks to whichever goddamn wizard figured this magic out.
485 |
486 | popcount = (i) ->
487 | i = i - ((i >> 1) & 0x55555555)
488 | i = (i & 0x33333333) + ((i >> 2) & 0x33333333)
489 | (((i + (i >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24
490 |
491 | rulesFromQuery()
492 |
--------------------------------------------------------------------------------