Когда мне скучно, я прибегаю к одному из привычных способов расслабиться и получить удовольствие. Например, могу побаловать себя бокальчиком хорошего пива. Иногда в процессе дегустации мне на ум приходят разнообразные идеи, и мне становится сложно сдержаться: неминуемо я начинаю городить ещё один бесполезный, но весёлый проект.
В одно прекрасное воскресенье, потягивая пиво и размышляя о жизни, я вдруг подумал: а можно ли вместить JavaScript-реализацию игры «Жизнь» в один твит? И не смог устоять перед желанием попробовать свои силы.
![](https://habrastorage.org/getpro/habr/upload_files/32f/1f8/1f7/32f1f81f7cd470113e92618e749535bd.png)
Это не настольная игра
Предположим, вы никогда не слышали об игре «Жизнь» и вдруг решаете зайти в Google и узнать, что это вообще такое. Скорее всего, первым, что попадется вам на глаза, будет вот такая настолка.
![Настольная игра The Game of Life, издание 1991 года (источник: amazon.com) Настольная игра The Game of Life, издание 1991 года (источник: amazon.com)](https://habrastorage.org/getpro/habr/upload_files/2da/5b3/e8b/2da5b3e8b2bfc0d559b1cac127dafc0e.png)
С большой долей вероятности она покажется вам довольно сложной, и вы подумаете — с какой стати я вообще пытаюсь втиснуть всю логику этой игры в 280 символов кода? Так вот. Это не та игра «Жизнь», что вы ищете.
Игра «Жизнь» Джона Конвея (Game Of Life) — вот как раз о ней пойдёт речь в этой статье. Всё действие происходит на двумерном поле с клетками. Каждая клетка может быть либо мертвой, либо живой. Состояние клетки может измениться после каждого раунда в зависимости от состояния ее соседей (клеток, расположенных рядом по горизонтали, вертикали или диагонали):
живая клетка останется живой в следующем раунде, если у нее есть два или три живых соседа, в противном случае она умирает;
мертвая клетка становится живой в следующем раунде, если у нее есть ровно три живых соседа, в противном случае она остается мертвой.
![Вот как это выглядит Вот как это выглядит](https://habrastorage.org/getpro/habr/upload_files/699/ddd/7b6/699ddd7b65d7c3e8437075c98d8bd5a0.gif)
Вот, собственно, и все. Для тех, кто хочет узнать про игру больше, есть статья в Википедии.
Отправная точка
Что я, собственно, имею в виду, когда говорю о JavaScript-реализации игры «Жизнь»? Конечно, я мог бы просто написать базовую функцию, которая принимает текущее состояние игры, творит некую магию и возвращает стейт для следующего раунда. Она без проблем уместится в одном твите. Но мне захотелось получить нечто более комплексное и самостоятельное. В моем представлении, код должен был генерировать начальное (случайное) состояние игры, запускать игру в бесконечном цикле и выдавать визуальное представление каждого раунда.
Я сел за ноутбук и принялся писать код. Буквально через пару минут у меня получилась работоспособная JavaScript-реализация, которая делала ровно то, что я хотел.
function gameOfLife(sizeX, sizeY) {
let state = [];
for (let y = 0; y < sizeY; y++) {
state.push([])
for (let x = 0; x < sizeX; x++) {
const alive = !!(Math.random() < 0.5);
state[y].push(alive)
}
}
setInterval(() => {
console.clear()
const consoleOutput = state.map(row => {
return row.map(cell => cell ? 'X' : ' ').join('')
}).join('\n')
console.log(consoleOutput)
const newState = []
for (let y = 0; y < sizeY; y++) {
newState.push([])
for (let x = 0; x < sizeX; x++) {
let aliveNeighbours = 0
for (let ny = y - 1; ny <= y + 1; ny++) {
if (state[ny]) {
for (let nx = x - 1; nx <= x + 1; nx++) {
if (!(nx === x && ny === y) && state[ny][nx]) {
aliveNeighbours++
}
}
}
}
if (state[y][x] && (aliveNeighbours < 2 || aliveNeighbours > 3)) {
newState[y].push(false)
} else if (!state[y][x] && aliveNeighbours === 3) {
newState[y].push(true)
} else {
newState[y].push(state[y][x])
}
}
}
state = newState
}, 1000)
}
gameOfLife(20, 20)
Можно было написать код получше? Да, пожалуй. Но у меня не было цели с первой попытки достичь идеала. Я вообще не ставил перед собой задачу написать идеальную с точки зрения кода реализацию игры. Мне нужна была лишь отправная точка, первичный код, который я бы сократил и компактизировал настолько, насколько это вообще возможно.
![Код запускается в Node.js и делает то, что ему говорят Код запускается в Node.js и делает то, что ему говорят](https://habrastorage.org/getpro/habr/upload_files/cf8/a0c/80c/cf8a0c80c92e0539e1fe61532725083f.gif)
Итак, давайте я вкратце объясню, что здесь вообще происходит. Я создал функцию gameOfLife
, которая принимает два аргумента: sizeX
и sizeY
. Они используются для создания двумерного массива state
, который заполняется случайными булевыми значениями (это делается во вложенных циклах for
. True
означает, что клетка жива, false
— что мертва).
Затем с помощью setInterval
каждую секунду выполняется анонимная функция. Она очищает текущий вывод консоли и формирует новый вывод, опираясь на данные о текущем состоянии. В этом выводе символом X
обозначаются живые клетки, а символом пробела — мертвые.
Далее с помощью еще одного набора вложенных циклов for создается новое состояние (newState
). Для каждой клетки (представленной в виде координат x, y) функция проверяет всех возможных соседей (от x-1 до x+1 и от y-1 до y+1) и подсчитывает количество живых (aliveNeighbours
). Для страховки из цикла исключается текущая ячейка, а также несуществующие соседи (например, x=-1, y=-1). На основании информации о количестве живых соседей устанавливается новое состояние ячейки. Итоговое состояние перезаписывает newState
.
Наконец, вызывается функция gameOfLife
с параметрами 20 строк на 20 столбцов. Вот и все.
Цель
На всякий случай поясню. Под твитом я подразумеваю пост в Twitter (социальной сети с птичкой), размер которого ограничен 280 символами.
В него мне и нужно уместить свой код. Конечно, отступы и длинные имена переменных никак не облегчают задачу, поэтому я оставлю их в исходном коде для лучшей читабельности, а затем воспользуюсь uglify-js для удаления лишних пробелов/строк и сокращения имен переменных (с именами длиной в один символ решить поставленную задачу будет легче).
После прогона через uglifier я получил исходный код длиной 549 символов. Чтобы впихнуть его в один твит, мне придется сократить его почти вдвое.
function gameOfLife(t,f){let s=[];for(let o=0;o<f;o++){s.push([]);for(let e=0;e<t;e++){const l=!!(Math.random()<.5);s[o].push(l)}}setInterval(()=>{console.clear();const e=s.map(e=>{return e.map(e=>e?"X":" ").join("")}).join("\n");console.log(e);const o=[];for(let l=0;l<f;l++){o.push([]);for(let f=0;f<t;f++){let t=0;for(let o=l-1;o<=l+1;o++){if(s[o]){for(let e=f-1;e<=f+1;e++){if(!(e===f&&o===l)&&s[o][e]){t++}}}}if(s[l][f]&&(t<2||t>3)){o[l].push(false)}else if(!s[l][f]&&t===3){o[l].push(true)}else{o[l].push(s[l][f])}}}s=o},1e3)}gameOfLife(20,20);
Рефакторинг
Итак, требования сформулированы, время терять нельзя, — приступаем к сокращению кода!
Декларации
Прежде всего, совсем не обязательно сначала объявлять именованную функцию, а затем вызывать ее. Я могу преобразовать ее в самовызывающуюся функцию, например ((sizeX, sizeY) => {...})(20, 20)
— этого вполне достаточно, и места она займет меньше.
Следующий момент — объявления переменных. В настоящее время я определяю переменные, когда они мне нужны, но это приводит к многократному появлению в коде let
и const
(слов длиной в целых 5 символов!). Давайте вместо этого один раз воспользуемся старым добрым 'var
' и объявим все переменные в начале функции.
((sizeX, sizeY) => {
var state = [],
y, x, consoleOutput, ny, nx, aliveNeighbours, newState;
...
})(20, 20)
А теперь пусть uglify-js сделает свою работу, и... мы получим 499 символов! Это все еще далеко от лимита Twitter, но вполне подходит для Mastodon (другой социальной медиаплатформы, конкурента Twitter).
![Скриншот поста на Mastodon с кодом игры Скриншот поста на Mastodon с кодом игры](https://habrastorage.org/getpro/habr/upload_files/03e/b26/6a3/03eb266a392d890ba73c775274933d99.png)
Сам пост вы можете посмотреть по этой ссылке.
((o,e)=>{var r=[],s,f,n,a,l,p,u;for(s=0;s<e;s++){r.push([]);for(f=0;f<o;f++){const h=!!(Math.random()<.5);r[s].push(h)}}setInterval(()=>{console.clear();n=r.map(o=>{return o.map(o=>o?"X":" ").join("")}).join("\n");console.log(n);u=[];for(s=0;s<e;s++){u.push([]);for(f=0;f<o;f++){p=0;for(a=s-1;a<=s+1;a++){if(r[a]){for(l=f-1;l<=f+1;l++){if(!(l===f&&a===s)&&r[a][l]){p++}}}}if(r[s][f]&&(p<2||p>3)){u[s].push(false)}else if(!r[s][f]&&p===3){u[s].push(true)}else{u[s].push(r[s][f])}}}r=u},1e3)})(20,20);
Причесываем генерацию начального состояния
Использование вложенных циклов for для задания начального состояния работает вполне неплохо, но можно сделать его еще лучше. Например, использовать метод Array.from
.
var state = Array.from(Array(sizeY), () => Array.from(Array(sizeX), () => Math.random() < .5 ? 'X' : ' ' ))
Array.from
принимает два аргумента. Первый является обязательным и представляет собой итерируемый объект, который будет преобразован в массив. Второй, необязательный, представляет собой callback. Значение, возвращаемое callback, помещается в выходной массив. Array(n)
возвращает массив длины n, заполненный пустыми значениями, но callback может их переопределить.
Поскольку и Array.from
, и Array
используются дважды, я могу сэкономить место с помощью переменных.
var array = Array,
arrayFrom = array.from,
state = arrayFrom(array(sizeY), () => arrayFrom(array(sizeX), () => Math.random() < .5 ? 'X' : ' ' )),
Возможно, сейчас этого не видно, но после того, как имена переменных будут изуродованы uglifier’ом, код станет на несколько символов короче.
Вы, наверное, заметили, что я больше не использую булевы значения. Поскольку для консольного вывода мне нужны символы X и пробела, проще использовать их и в состоянии. Благодаря этому код для управления консолью можно сократить:
console.clear()
console.log(state.map(row => row.join('')).join('\n'))
Как видите, я избавился от переменной consoleOutput
. Кроме того, для экономии места за счет манглинга я могу запихнуть консоль в переменную, так как она используется в коде дважды:
...
conzole = console,
...
conzole.clear()
conzole.log(...)
Еще несколько небольших корректировок (из-за использования X и пробела вместо булевых значений), и минифицированный код содержит... 448 символов. Осталось убрать меньше 200.
((r,o)=>{var e=Array,f=e.from,s=console,a=f(e(o),()=>f(e(r),()=>Math.random()<.5?"X":" ")),i,l,n,h,p,m,t;setInterval(()=>{s.clear();s.log(a.map(r=>r.join("")).join("\n"));t=[];for(i=0;i<o;i++){t.push([]);for(l=0;l<r;l++){m=0;for(n=i-1;n<=i+1;n++){if(a[n]){for(h=l-1;h<=l+1;h++){if(!(h===l&&n===i)&&a[n][h]==="X"){m++}}}}p=a[i][l].trim();if(p&&(m<2||m>3)){t[i].push(" ")}else if(!p&&m===3){t[i].push("X")}else{t[i].push(a[i][l])}}}a=t},1e3)})(20,20);
Переход в новое состояние
С самого начала мне не очень нравилась моя реализация newState
. Я сторонник использования методов массивов при работе с ними, поэтому решил применить reduce
и сократить количество циклов for
. Дополнительно я присвоил индикаторы состояния (символы X / пробел) новым переменным. Помимо этого, присвоение нового состояния клеток теперь обрабатывается эффективнее. Последнее улучшение в этой итерации — замена тройного знака равенства (===) на двойной (==) для сравнений.
...
alive = 'X',
dead = ' '
...
setInterval(() => {
...
state = state.map((row, y) => row.reduce((newRow, cell, x) => {
aliveNeighbours = 0
for (ny = y - 1; ny <= y + 1; ny++) {
for (nx = x - 1; nx <= x + 1; nx++) {
if (!(nx == x && ny == y) && state[ny]?.[nx] == alive) aliveNeighbours++
}
}
newRow.push(cell.trim()
? [2,3].includes(aliveNeighbours) ? alive : dead
: aliveNeighbours == 3 ? alive : dead
)
return newRow
}, []))
}, 1000)
После всех этих манипуляций у меня получилось 367 символов (естественно, в минифицированном виде). Неплохой результат, но все равно недостаточно емко для Twitter.
Ценить то, что уже есть
Как я уже говорил, я большой поклонник методов массивов (особенно reduce
). Однако здесь я активно использую и map
, и reduce
, а названия этих методов занимают довольно много места. Выше я уже применил Array.from
и поместил его в переменную, а после пары дополнительных минут изучения кода понял, что могу использовать его вместо map
и reduce
следующим образом:
state = arrayFrom(state, (row, y) => arrayFrom(row, (cell, x) => {
aliveNeighbours = 0
for (ny = y - 1; ny <= y + 1; ny++) {
for (nx = x - 1; nx <= x + 1; nx++) {
if (!(nx == x && ny == y) && state[ny]?.[nx] == alive) aliveNeighbours++
}
}
return cell.trim()
? [2,3].includes(aliveNeighbours) ? alive : dead
: aliveNeighbours == 3 ? alive : dead
)
}))
К тому же код, определяющий новое состояние каждой клетки, все равно мне не нравился (хотя и работал правильно), поэтому через какое-то время я пришел к такому решению:
return aliveNeighbours == 3
? alive
: aliveNeighbours == 2 ? cell : dead
После минификации я получил код длиной 321 символ.
((r,o)=>{var a=Array,n=a.from,e=console,f="X",l=" ",t=n(a(o),()=>n(a(r),()=>Math.random()<.5?f:l)),i,m,c;setInterval(()=>{e.clear();e.log(t.map(r=>r.join("")).join("\n"));t=n(t,(r,a)=>n(r,(r,o)=>{c=0;for(i=a-1;i<=a+1;i++){for(m=o-1;m<=o+1;m++){if(!(m==o&&i==a)&&t[i]?.[m]==f)c++}}return c===3?f:c===2?r:l}))},1e3)})(20,20);
Погружаемся ещё глубже
Что ж, вот я дошел до того момента, когда 40 символов стали казаться мне целой книгой. Что еще можно сократить и упростить? Следуя практике переиспользования имеющихся инструментов (Array.from
), я могу переписать вот этот фрагмент:
conzole.log(state.map(row => row.join('')).join('\n'))
следующим образом:
conzole.log(arrayFrom(state, (row) => row.join('')).join('\n'))
Конечно, в неминифицированном виде этот код длиннее оригинала. Однако после минификации он сократился до 319 символов, и я сэкономил целых 2 символа — это, мягко говоря, не так уж и много. Осталось еще 38.
Вообще-то не обязательно передавать size как два отдельных аргумента — это может быть один аргумент, используемый как для x, так и для y. И вообще вместо 20 я могу использовать 9, что сократит значение аргумента на один символ. Итак, сколько же мы выиграли? 311 символов минифицированного кода.
Что дальше? Допустим, я могу использовать числа — 0 для обозначения мертвой клетки и 1 для живой. Мы получим всего один символ вместо громоздкого трехсимвольного представления (0 вместо ' ' и 1 вместо 'X'). И поскольку это всего один символ, мне не нужно хранить его в отдельной переменной. 299 символов. Победа близка.
Теперь, используя числа в качестве индикаторов состояния, я могу немного подкорректировать логику, отвечающую за подсчет количества aliveNeightbours
:
...
for (ny = y - 1; ny < y + 2; ny++) {
for (nx = x - 1; nx < x + 2; nx++) {
if (state[ny]?.[nx] == 1) aliveNeighbours++
}
}
return aliveNeighbours - cell == 3
? 1
: aliveNeighbours - cell == 2 ? cell : 0
Я больше не проверяю, имеет ли потенциальный сосед те же координаты, что и клетка, для которой я считаю живых соседей. Вместо этого я вычитаю значение этой ячейки из итоговой суммы. Дополнительно я заменил nx <= x + 1
на nx < x + 2
(то же самое для y) — результат тот же, но на один символ короче. 286 символов. Осталось всего 6!
Я посмотрел на код, который генерирует uglify-js, и понял, что он сохраняет фигурные скобки для циклов for - for (...){for(...){...}}
. Но ведь их можно убрать и написать все одной строкой:
for (ny = y - 1; ny < y + 2; ny++) for (nx = x - 1; nx < x + 2; nx++) state[ny]?.[nx] == 1 && aliveNeighbours++
Прогоним это через uglify-js, и...
Наконец-то
Ровно 280 символов. Ну, технически 281 символ, но uglify-js добавляет точку с запятой в конце, а она мне не очень-то и нужна.
Вот окончательный вариант кода:
((size) => {
var array = Array,
arrayFrom = array.from,
conzole = console,
state = arrayFrom(array(size), () => arrayFrom(array(size), () => Math.random() < .5 ? 1 : 0 )),
ny, nx, aliveNeighbours;
setInterval(() => {
conzole.clear()
conzole.log(arrayFrom(state, (row) => row.join('')).join('\n'))
state = arrayFrom(state, (row, y) => arrayFrom(row, (cell, x) => {
aliveNeighbours = 0
for (ny = y - 1; ny < y + 2; ny++) for (nx = x - 1; nx < x + 2; nx++) state[ny]?.[nx] == 1 && aliveNeighbours++
return aliveNeighbours - cell == 3
? 1
: aliveNeighbours - cell == 2 ? cell : 0
}))
}, 1000)
})(9)
![В скрипте всего 280 символов, и он работает! В скрипте всего 280 символов, и он работает!](https://habrastorage.org/getpro/habr/upload_files/9b3/737/f4f/9b3737f4ff6b1d1d00f5b1aa5c706097.gif)
Твит
![А вот и сам твит А вот и сам твит](https://habrastorage.org/getpro/habr/upload_files/de3/d60/253/de3d60253447df9f78207c24cb016aae.png)
Предвосхищая комментарии — уверен, вы найдете способ «срезать» еще парочку символов. Возможно, у вас получится даже лучше, чем у меня!
Комментарии (9)
simenoff
25.07.2023 17:59Прежде всего, совсем не обязательно сначала объявлять именованную функцию, а затем вызывать ее. Я могу преобразовать ее в самовызывающуюся функцию, например
((sizeX, sizeY) => {...})(20, 20)
Зачем она вообще нужна?
simenoff
25.07.2023 17:59+7Шахматы на JS
// Tiny Chess (c)2010 Óscar Toledo G. for(B=i=y=u=b=i=5-5,x=10,I=[],l=[];B++<304; I[B-1]=B%x?B/x%x<2|B%x<2?7:B/x&4?0:l[i++]=("ECDFBDCEAAAAAAAA"+ "IIIIIIIIMKLNJLKM@G@TSb~?A6J57IKJT576,+-48HLSUmgukgg OJNMLK IDHGFE") .charCodeAt(y++)-64:7);function X(c,h,e,s){c^=8;for(var o,S,C,A,R,T,G,d=e&&X(c,0 )>1e4,n,N=-1e8,O=20,K=78-h<<9;++O<99;)if((o=I[T=O])&&(G=o^c)<7){A=G--&2?8:4;C= o-9?l[61+G]:49;do if(!(R=I[T+=l[C]])&&!!G|A<3||(R+1^c)>9&&G|A>2){if(!(R-2&7) )return K;n=G|(c?T>29:T<91)?o:6^c;S=(R&&l[R&7|32]*2-h-G)+(n-o?110:!G&&(A<2)+1); if(e>h||1<e&e==h&&S>2|d){I[T]=n;I[O]=0;S-=X(c,h+1,e,S-N);if(!(h||e-1|B-O|T-b|S< -1e4))return W(),c&&setTimeout("X(8,0,2),X(8,0,1)",75);I[O]=o;I[T]=R}if(S>N||!h &S==N&&Math.random()<.5)if(N=S,e>1)if(h?s-S<0:(B=O,b=T,0))break}while(!R&G>2||( T=O,(G||A>2|(c?O>78:O<41)&!R)&&++C*--A))}return-K+768<N|d&&N}function W(){i= "<table>";for(u=18;u<99;document.body.innerHTML=i+=++u%x-9? "<th width=60 height=60 onclick='I[b="+u+ "]>8?W():X(0,0,1)'style='font-size:50px'bgcolor=#"+(u-B?u*.9&1||9:"d")+ "0f0e0>&#"+(I[u]?9808+l[67+I[u]]:160):u++&&"<tr>")B=b}W()
formerchild
25.07.2023 17:59Мой вариант рисует поле покрасивее, но 294 символа, эх...
(size => { let cons = console, arr = [...Array(size).keys()], st = arr.map(i => arr.map(j => Math.random() < .5 ? 1 : 0)), sum = f => [-1,0,1].map(f).reduce((a, b) => a+b); setInterval(_ => { cons.clear(); cons.log(st.map(r => r.map(c => ["⬛","⬜"][c]).join("")).join("\n")); st = st.map((r, i) => r.map((c, j) => [c,1][sum(ii => sum(jj => (st[i+ii]?.[j+jj])||0)) - c - 2]||0) ); }, 1000); })(9);
formerchild
25.07.2023 17:59Но по сравнению с вариантом статьи экономия 7 символов (если убрать красивые кубики ["⬛","⬜"]) из моего кода то после uglify останется всего 273 символа)
mentin
Я бы не вычитал cell из соседей, а просто переписал бы правило в терминах “инклюзивных" соседей : 3 тогда живём, 4 если сам живой. Кажется 4 символа может сократить.
mentin
Спасибо за статью, кстати, до боли напомнило впихивание Тетриса в МК85 - там был 1 КБ для программы на бейсике, удалось впихнуть.