├── 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 | 3 | Automata by Example 4 | 5 | 6 | 7 |
8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | 26 | 30 |
31 |
32 |

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 | --------------------------------------------------------------------------------