Pull to refresh

Javascript и canvas в игре «Жизнь» Джона Конвея

Reading time 14 min
Views 20K
Напишем эту алгоритмическую игру [1] так, чтобы извлечь из неё максимальную образовательную пользу в области алгоритмов, языка Javascript, хорошего стиля программ, умения оптимизировать код. Центральным местом обсуждения будет не игра, а код, способы реализации, оптимизация.

Неоднократно встречалось в литературе и статьях мнение, что эту игру знает каждый программист и что почти каждый пробовал её программировать. Например, разные авторы писали, что через неё изучают новые для них языки программирования.

Недавно проведённый на Хабре опрос [3] показал реальную картину — 20% программистов написали когда-либо её работающую реализацию, а порядка 10% о ней не слышали. Что ж, тем интереснее будет оставшимся 80% узнать, что можно извлечь из реализации игры.

Что это за игра?


Полноценный ответ даст тег «игра жизнь» на хабре [1] или статья из Википедии [2]. Другой перевод названия игры на русский — «Эволюция» (встречалось в журнале «Наука и жизнь» 70-х годов). Другой перевод фамилии автора на русский — Конуэй (John Conway, game «Life»). Вот что говорит сам Конвей об игре (youtube, 4 мин.) [4], там же — наглядный рассказ о правилах игры (англ.).

Возможно, причина непоголовной популярности в узких кругах в том, что, как написал в комментарии Danov, "… варкрафты и прочие симуляторы свели на нет восторг от движущихся квадратиков". (Но настоящих математиков должно интересовать не это?)

Если поискать результаты на youtube.com по словам «life conway», то найдём массу примеров демонстрации игровых полей как «Жизни» [5], так и игр по похожим правилам, затем по связям — демо эволюционных алгоритмов, поиска кратчайшего пути обхода и другие интересные наглядные демо. Стало быть, над ними где-то идёт упорная «плодотворная» работа.

Но каковы плоды? После насыщения мозга визуальными эффектами придётся признать, что это довольно бесполезная задача, если не исследовать на ней другие проблемы. Например, скорость визуализации в конкретном языке разными методами, управление библиотеками исходных данных.

Множество реализаций на Javascript можно найти поиском по google — «game life javascript». Но, в силу врождённой природной лени образованных людей, не будем вдаваться в их детали. Уже потом, после реализации, попробуем когда-нибудь сравнить эффективность того или другого решения. (Я это попробовал сделать, но дальше 2-го решения смотреть не захотелось по качеству результата.)

Идея реализации


Сделаем её на торе (точнее, спирали), организованном в виде линейного массива, чтобы не заботиться о граничных эффектах. Параметры высоты и ширины — переменные, поэтому предельная скорость смены поколений может регулироваться размерами рабочего поля. Начальная точка массива смещается с каждым поколением, чтобы результаты записывать в тот же массив. На скорость будет влиять метод визуализации: текст, таблица с фоновыми цветами, canvas, рисунки.

Почему именно так? Индекс одномерного массива вычисляется наиболее быстро, поэтому, если задача приводима к одномерному массиву, этим полезно воспользоваться. Помнится, так я её делал на GW-Basic на текстовом поле 80 на 50, чтобы предельно ускорить, и удовлетворил любопытство наблюдения за эволюцией наиболее долгоиграющей 5-точечной фигуры, напоминающей знак квадратного корня. Правда, в тор 80 на 50 она не поместилась.

На JS удобно её сделать потому, что скорости и возможности современных браузеров дают изобразить всё необходимое на одной странице, следовательно, отладка будет быстрой и удобной, а рабочей среды не нужно — всё есть в браузерах. Полезен будет универсальный текстовый редактор с подсветкой синтаксиса (Notepad++, E Editor, UltraEdit, ...).

Неделя ООП на Хабре


Далее, вспомним, что мы, пишущие просветительские статьи, в ответе за всех кого приучим писать плохо в JS, в традициях 2000-2005-х годов, когда никто не задумывался о глобальных переменных. Я до вчерашнего дня тоже не задумывался и писал как придётся, но тут вдруг осенило… Давайте даже такую простую задачу оформим в лучших традициях ООП, инкапсулируя детали реализации в объект. Не зря ведь на Хабре прошла неделя ООП:

habrahabr.ru/blogs/cpp/111120 — «10 лет практики. Часть 1: построение программы».
habrahabr.ru/blogs/development/111125 — «Мысли об ООП».
habrahabr.ru/blogs/development/111162 — «Зачем ООП».
МакКоннел — «Совершенный код».
habrahabr.ru/blogs/javascript/111393 — «Обёртки для создания классов: зло или добро?».

Правда, классов и наследования нам будет не нужно, только инкапсуляция реализации в конструкторе.

Ниже будет скрипт с теми самыми перегруженными методами, которые, если бы применялись в наследовании, дали бы тот самый не рекомендуемый в последней статье эффект размножения функций и траты памяти. Но писать неоправданно на каждой функции слово «prototype» тоже не хочется и дублировать решения Mootools в нативном коде — тоже.

Вообще, хороший обзор по способам организации ООП в JS лежит известно где [6], поэтому следующим шагом для самостоятельного написания хорошего кода должно стать изучение этой статьи. Но пока я не вижу настоятельной необходимости перехода к различным стилям кодирования.

Этим страдают все простые проекты: пока не возникнет необходимости писать «хорошо» в смысле большого, живого и расширяемого проекта, руки пишут «плохо». Думаю, все проходили стадию «плохого письма», пока что-то не заставило писать хорошо. Интересно будет услышать мнение опытных товарищей, как, например, в этом проекте найти мотивацию для написания хорошего кода.

Скрипт


Чтобы не засорять статью долгими кодами с подсветкой (спойлеров на хабре нет), опубликуем их на другом ресурсе.
   КОД НАХОДИТСЯ ЗДЕСЬ
Написаны функции для минимального комфорта и «подогрева дальнейшего энтузиазма»: рабочее (игровое) поле нужного размера, несколько стартовых полей, варианты оптимизаций. Теперь остаётся простор для развития, для сравнения методов оптимизации и проверка того, не перестарались ли в усердии, в затратах времени вычислений, да и во времени написания кода.

Как и где пользоваться скриптом


Достаточно взять приведённый код через copy-paste в страницу HTML, чтобы увидеть его работу. Или посмотреть страницу spmbt.github.io/spmbt/lifeConway.htm, [дубль].

Если размер поля слишком большой для экрана монитора (у меня требуется не менее 1400 на 1050), уменьшаем числа в полях «Ширина, „Высота“, затем нажимаем на одну из кнопок „Init...“. Простейший интерфейс на кнопках позволяет пронаблюдать описанные в статье действия со скриптом и время исполнения от нажатия кнопки до остановки выполнения.

Визуализация


Для начала выбран самый бесхитростный вариант показа — текст, полагая, что с показом текста у всех браузеров должна быть отличная скорость обновления. Потом, после работы с текстом, можно попробовать графику в разных представлениях, чтобы сравнить скорости.

Действительно, замеры скоростей первого варианта на Опере 10.51 показало, что время расчёта нового поколения было 30%, а время отрисовки — 70%. Неплохое соотношение для визуализации. И за 10 секунд отрисовывалось 1000 полей размером 180х150. Если всё запустить в рабочем цикле с просмотром каждого поколения, будет медленнее (reflow) — за 75 секунд (сильно зависит от процессора, браузера и видеокарты).

Сравнение скоростей работы скриптов в браузерах


Результаты проверки на случайном заполнении поля 180х150, 100 поколений. (E2180, 2.0 ГГц, видео Radeon X1650, WindowsXP)
(Для корректности всё поле должно быть на экране, окно — в фокусе, других действий на компьютере — не производиться.)
IE8 34c.
Opera 10.51 7.5c.
FF 3.6.10 125c.
Chrome 8 4.3c.
Safari 5.02 25c.
Да, в FF с этим скриптом — полный провал на фоне остальных. При очень больших полях без движений мыши над окном даже пропускает кадры — экономит отображение, обнаруживая полную загруженность скриптом. Как показывают другие замеры через doStep2(), тормозит расчёт в массивах: 1.3 секунды на поколение. В то же время, 23 секунды на 1000 отрисовок поля.

Оптимизация для Firefox


Анализ показывает, что 80% времени расчёта массива 180х150 отъедает перемещение вычисленных значений (w+1 штук, немного) в начало. И интересный факт, что первый расчёт поколения — на 100 мс быстрее следующих (за это „отвечает“ a.splice(0, w1+w1);). Значит, источник торможения — не столько сам массив, сколько часть работы с ним.

Конечно, когда код был написан первый раз, он создавался сознательно кратко, в ущерб оптимизациям, но в пользу выразительности. Переписываем перемещение значений другим способом. И первый цикл, и последующие стали длительностью не 120-200 мс, а 90-100. (Будем знать, что .splice() реализован в FF неудачно.) Новый код:
	var b=[];
	b = a.slice(iStart +w1+1, iStart +w1+w1+1).concat(a.slice(w1+w1, iStart+w1+1));
	a=b;
Тестирование всех браузеров на новом коде.
IE8 14.5с.
Opera 10.51 7.0c.
FF 3.6.10 12.5c.
Chrome 8 4.1c.
Safari 5.02 5.5c.
Оказалось, что оптимизация FF помогла заметно ускориться другим браузерам (кроме Opera-Chrome, которые и так быстры).

Что полезного получили от абстрактной игры? Направления дальнейших занятий


Выбор языка заметно облегчил разработку, потому что имеются необходимые средства как расчётов, так и визуализации. Появились данные о сравнении разных методов визуализации в разных браузерах, оценено время разработки задачи визуализации, похожей на настоящую. Возможно, кто-то найдёт аналогичную выгоду от техник оптимизации и организации тестов.

Для будущего развития подготовлена подходящая база выбором обдуманного дизайна кода программы. В зависимости от того, в каком направлении будет актуальна дальнейшая специализация, можем выбрать пути дальнейшего совершенствования программы.
1) интерфейс работы с мышью: кликами и протяжками мыши вставлять точки, очищать области, добавлять и вращать библиотечные элементы, расширять или обрезать рабочее поле, сдвигать всё игровое поле;
2) хранить библитечные элементы;
3) отработать профилирование работы частей программы;
4) визуально отображать статистику поколений в игре: изменение цвета точек в зависимости от возраста, показывать плотность и активность изменений;
5) оптимизация скорости расчёта за счёт обнаружения и исключения пустых полей (вести статистику пустого окружения на базе тех же okr, чтобы на следующий ход знать, вычислять ли здесь новое значение);
6) бросить всё и заниматься делом: в реальных программах тоже можно отработать для себя совершенные методики — за это ещё платят.

Иные формы „Жизни“


Среди правил поведения клеточных автоматов возможны вариации с интересными результатами. Упоминали клон правил с гексагональной решёткой. Можно представить и попытаться визуализировать трёхмерные решётки, наблюдать время жизни ячеек, изменить правила на простой решётке. Например, достаточно давно математики предложили клон игры с учётом возраста клеток — молодые более выносливы, чем старые. tangro сообщил, что писал многопользовательскую игру: „два игрока строят на своих половинах поля фигуры с целью уничтожить фигуры противника“. Есть варианты алгоритмов клеточных автоматов на графах.

Пример изменения правил


Попробуем расширить правила в рамках традиционных клеток (метод .step()). Если свести правила не к логике, а в таблицу, то получится простая и универсальная таблица — зависимость следующео значения клетки от суммы значений окружающих клеток.
Число точек окружения Следующее значение поля,
если было 0
Следующее значение поля,
если было 1
0 0 0
1 0 0
2 0 1
3 1 1
4 0 0
5 0 0
6 0 0
7 0 0
8 0 0
Вот и все правила. Реализация основного цикла через таблицу показала, что „просто логика“ работает на 10% быстрее. Поэтому классическую игру лучше писать через простую логику (здесь, для JS), а табличный цикл используем для изысканий того, какие могли бы быть правила. В реализации этот режим включают радиокнопкой „Кл. на матр.“ (»Классика на матрице"). В смеси с отображением на экране замедления расчётов совершенно не заметно (около 1%, по оценке). Его можно использовать для экспериментов по модификации правил игры.

//правило поколения (классика)
var prav = [0,0,0,1,0,0,0,0,0,0, 	0,0,1,1,0,0,0,0,0,0];
 //основной цикл (180*150, 1000 поколений за 4.5 с.(без графики))
for(i = iStart + w1; i >= w1; i--){
	okr = a[i-w1] + a[i-w] + a[i-w1m]
		+ a[i-1] + a[i+1]
		+ a[i+w1m] + a[i+w] + a[i+w1];
	a[i+w1] = prav[10*a[i] + okr];
}

Несложно заметить, что небольшие изменения в правилах приводят или к пустым полям, или к хаосу (а может быть, это не хаос, а настоящая жизнь? :) ), а самое занимательное поведение происходит именно по конвеевским правилам.

Модификация И.Сидорова (1975 г.)


Пройдём по пути модификации правил, (старая статья в «Науке и жизни» 1975 г., [10]), которые учитывают возраст точек. Молодые более жизнестойки, но и более агрессивны к старым. Автор модификации — и есть автор статьи, инженер-физик И. Сидоров.

Вот описание на словах из livejournal: "… другие правила — клетки двух типов, молодая и старая. После рождения клетка 1 ход — молодая, потом превращается в старую. Молодые клетки не умирают. Старые клетки без молодых соседей выживают по Конуэевским правилам. Старые клетки с молодыми соседями должны иметь 2 соседа (либо обе-молодые, либо — молодая и старая), иначе она умирает. Рождаются клетки по таким же правилам, как у Конуэя."

Чтобы описать правила выживания старых клеток и уложиться в ту же решающую матрицу, понадобится назвать молодую клетку числом 5, а старую — числом 4. Получаем такую решающую матрицу.
Сумма значений точек окружения Следующее значение поля,
если было 0
Следующее значение поля,
если a[i] было =5 («молодая» точка)
Следующее значение поля,
если a[i] было =4 («старая» точка)
с 0 по 7 0 4 0
с 8 по 11 0 4 4
12 5 4 4
с 13 по 15 5 4 0
более 16 0 4 0
Для реализации напишем код
if(rule ==11){ //правила с учётом молодости точек
	var prav = [0,0,0,0,0, 0,0,0,0,0,   0,0,5,5,5, 5,0,0,0,0, 	0,0,0,0,0, 0,0,0,0,0,	0,0,0,0,0, 0,0,0,0,0,0
	           ,0,0,0,0,0, 0,0,0,0,0,   0,0,0,0,0, 0,0,0,0,0, 	0,0,0,0,0, 0,0,0,0,0,	0,0,0,0,0, 0,0,0,0,0,0
	           ,0,0,0,0,0, 0,0,0,0,0,   0,0,0,0,0, 0,0,0,0,0, 	0,0,0,0,0, 0,0,0,0,0,	0,0,0,0,0, 0,0,0,0,0,0
	           ,0,0,0,0,0, 0,0,0,0,0,   0,0,0,0,0, 0,0,0,0,0, 	0,0,0,0,0, 0,0,0,0,0,	0,0,0,0,0, 0,0,0,0,0,0
	           ,0,0,0,0,0, 0,0,0,4,4,   4,4,4,0,0, 0,0,0,0,0, 	0,0,0,0,0, 0,0,0,0,0,	0,0,0,0,0, 0,0,0,0,0,0
	           ,4,4,4,4,4, 4,4,4,4,4,   4,4,4,4,4, 4,4,4,4,4, 	4,4,4,4,4, 4,4,4,4,4,	4,4,4,4,4, 4,4,4,4,4,4];
	for(i = iStart + w1 + w1; i >=0; i--){if(a[i] ==1) a[i] =4;}
	for(i = iStart + w1; i >= w1; i--){ //основной цикл
		okr = a[i-w1] + a[i-w] + a[i-w1m]
			+ a[i-1] + a[i+1]
			+ a[i+w1m] + a[i+w] + a[i+w1];
		a[i+w1] = prav[41*a[i] + okr];
	}
}
И дополним метод this.show() строчкой для цифр «4» и «5»:
var s = a.join('').replace(/0/g,'\xb7').replace(/[14]/g,'o').replace(/5/g,'s');
Посмотрим, как с этими правилами работает поле со случайным заполнением. Перейти к просмотру игры по этим правилам в законченной программе можно, выбрав радиокнопку «Sidorov's».

Правила проверены и перепроверены, прочитаны даже ещё раз на скане журнала. Ошибок в реализации нет, статические конвеевские конструкции, действительно, живут, «кванты» летают (проверяется, немного изменив initLib(), подставив «квант для клона»). Но, к сожалению, случайно заполненное превращается в хаос. Из чего можно сделать вывод, что правила несовершенны. Наверное, поэтому о них дальнейших упоминаний не было. Крупные хаотические структуры легко неопределённо долго живут без прихода к циклическим вариантам. Очевидно, причина — в избыточной устойчивости молодых клеток. Им нужно определить некоторые правила умирания при окружении более N соседей, тогда, может, что-то получится.

Таким образом, мы создали инструмент для модификации и исследования иных правил игры. Хорошо было бы разобраться, как сделали симметричную (относительно 0 и 1) вариацию игры [7], но придётся это сделать не сейчас, потому что близится 11 января.

В мире существует много программ, поддерживающих игру и её модификации. Существуют даже форматы сохранения данных для игры. Для знакомства достаточно посмотреть на один из сборников ссылок по теме [9]. Поэтому, на самом деле, процесс интеграции только что описанной программы с огромным множеством наработок у нас ещё даже не начинался, а часы, уже потраченные другими исследователями на изыскания, измеряются тысячами.

UPD (12.01.2011): Вариация правил Day & Night [7]


В программу добавлена вариация правил "Day & Night", описанная в Википедии и интересная тем, что при смене значений полей с 0 на 1 и наоборот — ничего не изменится, просто мир станет «инверсным», но живущим по таким же законам. С подготовленной функциональностью сделать дополнение оказалось очень просто (к чему и стремились).
if(rule==2){
	var prav = [0,0,0,1,0, 0,1,1,1,1, 	0,0,0,1,1, 0,1,1,1,1]; //правило поколения (DayNight)
	for(i = iStart + w1; i >= w1; i--){ //основной цикл
		okr = a[i-w1] + a[i-w] + a[i-w1m]
			+ a[i-1] + a[i+1]
			+ a[i+w1m] + a[i+w] + a[i+w1];
		a[i+w1] = prav[10*a[i] + okr];
	}
}
В этом мире существуют свои осцилляторы. Поле стремится перейти к статическим и осциллирующим объектам, лёгкого возникновения движущихся объектов не наблюдается, но они есть. Все правила и небольшое введение можно почитать на английском в приложенном там архиве, но они видны и приведённом выше коде. Готовую программу можно запускать там же, где остальные, на одной из страниц демонстрации: [*], дубль; на codepaste.ru дополнения нет (почему-то не могу авторизоваться, чтобы сменить версию кода). Нужно выбрать радиокнопку «DayNight», а дальше — как обычно.

Для наблюдений немного изменены начальные условия на кнопках: случайное поле заполняется с плотностью не 1/6, а 1/2. Оно с течением времени склонно разбиваться на небольшие «белые» и «чёрные» области, в которых живёт статика и осцилляторы. Исходная матрица блоков 2х2 с 1 лишней точкой «взрываться» не думала, поэтому добавил ещё одну точку для инициации процесса. После чего «взрыв» тоже получается красивый и своеобразный.

Долгоживущая в обычной «Жизни» фигура (кнопка «Init 'r'») оказывается в этом мире осциллятором с периодом 16. Чтобы можно было удобно задавать любые фигуры, дописан цикл выкладки фрагмента поля (например, на кнопку «Init 'r'» повешено взятое из описания "[Figure 15. Two p32 ships collide to form a rocket]" — столкновение 2 кораблей). Затем получившаяся ракета направлена на осциллятор. К 400-му ходу видим «клубящееся облако» после столкновения ракеты с осциллятором, которое вскоре пропадает.


Как оказалось, это довольно интересная модификация игры, с собственным своеобразным поведением.

Визуализация на canvas


Думать о лучшей визуализации заставляют 3 причины:
1) влияние стилей отображения текстов в браузерах на текстовые строки, составляющие игровое поле (есть непреодолимые настройки браузера «минимальный размер шрифта»);
2) время отображения больше времени вычисления шага;
3) большие поля в браузерах работать могут, но при размере клетки в 1 символ они не помещаются в окне.

Не будем испытывать нагромождения рисунков, ячеек таблиц, блоков, абсолютные позиции десятков тысяч элементов — есть сомнения в их простоте организации, следовательно — в скорости работы. Будем рисовать на холсте canvas.

Используем для начала быстрый, но не самый эффективный метод: отрисовку каждой точки при каждом ходе, чтобы оценить мощность. Понятно, что затем его можно ооптимизировать, отрисовывая спрайты (putImageData()). Но даже в первом приближении canvas даёт от него ожидаемое: скорость работы возрастает, и масштаб можно уменьшить в 2-3 раза.

Чтобы включить режим canvas («холст»), включаем чекбокс «на canvas», меняем размер точки («размер»), варианты отрисовки (чекбоксы «rect», «сетка»), затем выбираем один из «init ...», затем — «Step».
Устроим отрисовку 4 вариантов: круги, прямоугольники, с сеткой и без неё. Конечно, прямоугольники должны рисоваться быстрее, что подтверждают опыты. При малых клетках уже не имеет значения форма точки, поэтому имеем экономию без потери качества.

Сетка — явно невыгодное занятие — рисовать каждую точку. Если бы понадобилось оптимизировать, лучше делать canvas немного прозрачным, а под ним располагать сетку. Ещё лучше, объявить фоновый рисунок на самом canvas. В нашей программе мы можем оценить, насколько отрисовка сетки невыгодна. Если точек немного (100 на 200), то можно согласиться с их отрисовкой.

Заметим, что теперь чем больше точек на экране, тем медленнее работает скрипт, потому что ему надо рисовать каждую непустую точку. Ещё заметно, что рисование окантовки круга (stroke()) очень плохо переваривается скриптом. Поэтому я не стал выводить её в чекбокс, а просто закомментировал — кто интересуется, тот проверит.

Под конец выложим самые красивые достижения: «взрыв» регулярной матрицы блоков в течение 200 ходов на поле 408 на 402 в наиболее быстром для canvas браузере (Opera); таблицу скоростей обработки разных режимов в разных браузерах; картинку результата «взрыва» блочной матрицы" — из 1 точки действие выполнилось за 38 секунд, 5.3 шага в секунду.

Жизнь случайно заполненного начального поля, 100 шагов (e2180, 2.0 ГГц, ...)
Браузер, 180x150, время выполнения (с) текст, 12px canvas, круг, 6px, сетка canvas, круг, 6px canvas, квадрат, 6px canvas, квадрат, 4px
(вид клеток)
IE8 14.5 - rest in peace
Opera 10.51 7.7 18 6.8 4.5 4.2
FF 3.6.10 15 41 14.5 12 14
Chrome 8 4.2 46 7.6 5.0 4.0
Safari 5.02 5.5 61 10.8 5.3 4.8


Разрушение стабильной матрицы из блоков 2x2, вызванное одной лишней клеткой.


Напомним, что это ещё не самый быстрый canvas, достижимый в алгоритме, использование спрайтов должно улучшить показатели. В целом же мы видим, что Джаваскрипт закономерно, с совершенствованием браузеров, показывает свою силу и предоставляет удобную среду для экспериментов в решении прикладных задач.

Заключение


Выполнен вариант программирования игры. Часть строк кода написаны без учёта принципов качественного кода — например, есть повторения операторов. Вероятно, к качеству кода нужно выработать чутьё, а, возможно, это есть нормальная практика — писать не используемый в будущем код «как придётся», а затем переписывать заинтересовавшие части.

Ссылки


*. Работающая реализация для статьи. (11.01.2011), дубль.

1. Выборка по тегу «игра жизнь»

2. Википедия — Жизнь (игра)

3. Опрос для программистов: писали ли Вы реализацию игры «Жизнь»Конвея?

4. John Conway Talks About the Game of Life Part 1 (4:10)
www.conwaysgameoflife.net — Conway's Game of Life

5. youtube.com, «game life conway». Наиболее интересные ролики:
5.1. Amazing Game of Life Demo (4:47) много очень сложных организованных конструкций.
5.2. Cellular Automata: Conway's Game of Life (3:56)
5.3. Conway's Life — Cyclic Universe (0:40)

6. ООП в Javascript: наследование. И. Кантор

7. Day & Night, симметричная реализация правил относительно значений полей «0» и «1» в игре, подобной «Жизни».

8. Математическая игра “Жизнь”. Другие программы и описание игры.

9. Ссылки по игре «Жизнь».

10. Описание модифицированных правил с учётом возраста клеток из статьи в «Науке и Жизни», 1975, #3, «Эволюция игры „Эволюция“», И.Сидоров (архив — DjVu). Сканы этой и похожих статей.

*) Идея для ненормального программирования: игра Жизнь на Экселе без макросов.

**) Во время написания статьи ни один рабочий час не пострадал.
Tags:
Hubs:
+52
Comments 23
Comments Comments 23

Articles