Живой узор в QtE5

В этой статье я познакомлю вас с еще одним удивительным примером самоорганизации, который позволяет наглядно (в режиме реального времени) пронаблюдать как из беспорядочного хаоса раскрашенных клеток возникают по своему красивые узоры. Этот пример, отчасти является дальним и очень упрощенным родственником одного из наших проектов по построению клеточного автомата с помощью QtE5, однако, правил в предлагаемом мной эксперименте гораздо меньше, что делает его более притягательным для собcтвенных опытов.

Идея проекта, как ни странно, взята из одного из старых учебников по Delphi, где приложение позиционировалось как пример «живого узора».

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

Стоит заметить, что это очень важная деталь, поскольку палитра является важным компонентом алгоритма по смене состояния клеток, роль которого, в данном случае, выполняет цвет. Область рисования представляет собой, как нетрудно догадаться, прямоугольник M на N однородных клеток, и в некоторый момент времени каждая клетка которого имеет свой определенный цвет: данный цвет на начальном этапе действия алгоритма выбирается случайным образом из палитры.

После того, как все клетки поля получили свой цвет, вступает в действие алгоритм, который использует крестообразную окрестность для каждой клетки, т.е клетка имеет четыре соседа — клетка сверху, клетка снизу, клетка слева и клетка справа. Алгоритм «живого узора» выбрав некоторую клетку, анализирует ее окрестность, и если в окрестности есть хотя бы одна клетка следующего цвета по палитре (по сравнению с цветом текущей клетки), то клетка перекрашивается в этот цвет, меняя свое состояние. Важный момент: если текущий цвет клетки является последним в палитре, то следующим цветом в этом случае будет самый первый цвет палитры. Также, отмечу тот факт, что если ни один из соседей клетки не окрашен в следующий цвет, то никакой смены состояния не происходит.

Еще одним важным моментом является то, что поле M на N является замкнутым по его краям, т.е точно такая же ситуация, как и в проекте по созданию WireWorld: самая левая клетка является соприкасающейся с самой правой клеткой и то же самое верно для самой верхней и самой нижней клетки, которые также являются соприкасающимися.

Приступая к программированию создадим проект для dub и перенесем в его папку source свежую версию файла qte5.d, взятую из репозитория проекта QtE5.

После этого приступим к созданию ограниченной палитры, а также по ходу ее описания создадим среду для выполнения механизмов «живого узора», для чего создаем файл cell.d в папке source проекта dub и размещаем в нем следующую часть кода:

module cell;

private
{
	import std.algorithm;
	import std.conv;
	import std.range;
	import std.string;

	import qte5;

	enum string COLORS = 
	`
		255, 0, 0, 230;
		0, 255, 0, 230;
		255, 255, 224, 230;
		0, 0, 255, 230;
		255, 255, 255, 230;
		130, 130, 130, 230;
		255, 0, 255, 230;
		0, 128, 128, 230;
		0, 0, 128, 230;
		128, 0, 0, 230;
		191, 255, 0, 230;
		112, 130, 56, 230;
		128, 0, 128, 230;
		192, 192, 192, 230;
		146, 211, 202, 230;
		0, 0, 0, 230;
	`;

	auto createPalette(string colors)
	{
		QColor[] palette;

		QColor getColor(string color)
		{
			QColor qcolor = new QColor(null);

			auto tmp = color
						.strip
						.split(",")
						.map!(a => a.strip.to!int)
						.array;


			qcolor.setRgb(tmp[0], tmp[1], tmp[2], tmp[3]);

			return qcolor;
		}

		
		palette = colors
					.strip
					.split(";")
					.filter!(a => a != "")
					.map!(a => getColor(a[0..$]))
					.array;

		return palette;
					
	}

	enum NUMBER_OF_COLORS = COLORS
								   .strip
								   .split(";")
								   .filter!(a => a != "")
								   .map!(a => a.strip)
								   .array
								   .length;
}

Здесь сначала мы импортируем необходимые модули стандартной библиотеки, а также все содержимое библиотеки QtE5. Библиотека QtE5 обеспечит нас очень удобными способами описания цвета клеток и их последующей отрисовки, которые мы дополним по аналогии с уже упоминавшимся проектом WireWorld.

Затем создаем простую строку COLORS, которая вычислится на этапе компиляции проекта и которая содержит описание цветов палитры в формате RGBA (цвет RGB + прозрачность). Цвета описываются в простом формате в виде целых чисел для RGBA, разделенных запятыми. Отдельные «цветовые единицы» разделены точкой с запятой с последующим переводом строки, что облегчит последующий разбор строки палитры в конкретные цвета типа QColor. После того, как строка COLORS будет создана компилятором, создается функция createPalette, которая превращает строку COLORS в массив элементов QColor: строка, передаваемая в качестве аргумента, очищается от пробельных символов с начала и с конца строки, разбивается по разделителю «двоеточие». Полученный массив строк содержащий описание отдельных «цветовых единиц», очищается от пустых строк с помощью шаблона filter, после чего происходит превращение строки с описанием цвета в цвет, определяемый через QColor, что достигается использованием внутренней функции getColor в сочетании с шаблоном map из стандартной библиотеки. Дальше выполняется, ставшее уже стандартным для нас, превращение диапазона цветов типа QColor в динамический массив элементов типа QColor.

Внутренняя функция getColor работает почти также, как и описанная в одном из рецептом функция извлечения цвета из CSV-описания: она предполагает, что в качестве аргумента функции передана строка, в которой перечислены значения RGBA, описанные через запятую. С помощью strip предполагаемая строка очищается от пробельных символов, разбивается по разделителю «запятая» посредством split, после чего шаблоном map в сочетании с шаблоном to происходит превращение массива строк в числовые целочисленные значения. Дальнейшее — это просто дело техники: перед работой разбора создается объект типа QColor, которому после окончания обработки строки-аргумента по очереди присваиваются ранее выделенные числовые значения, сохраненные в массив с помощью шаблона array.

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

Для этого, на этапе компиляции в переменную NUMBER_OF_COLORS производим вычисление количества цветов: строку COLORS очищаем от пробельных символов, разбиваем по разделителю «многоточие», отфильтровываем строки не являющиеся пустыми, обрабатываем полученный массив строк с помощью map и функции strip. После этого, остается преобразовать результат в массив и вычислить его длину, которая будет равна количеству выделенных цветовых единиц (а оно в точности равно количеству двоеточий).

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

template addProperty(T, string propertyVariableName, string propertyName)
{
	import std.string : format;
 
	const char[] addProperty = format(
		`
		private %2$s %1$s;
 
		void set%3$s(%2$s %1$s)
		{
			this.%1$s = %1$s;
		}
 
		%2$s get%3$s()
		{
			return %1$s;
		}
		`,
		propertyVariableName,
		T.stringof,
		propertyName
		);
}

class Colors
{
	mixin(addProperty!(QColor[NUMBER_OF_COLORS], "colors", "Colors"));	

	this(string palette)
	{
		colors = createPalette(COLORS)[0..NUMBER_OF_COLORS];
	}
}

Имея класс цветовой палитры, плавно переходим к определению класса, который отвечает за сам «живой» узор.

Такой класс можно построить слегка переписав и доработав класс WireWorld, с учетом того, что цвета узора удобнее представлять в виде обычных чисел от 0 до NUMBER_COLORS-1, применяя эти числа и в вычислениях следующего шага нашего автомата, и в качестве индексов массива палитры, которую содержит класс-контейнер Colors.

Класс Tracery с шаблонными параметрами длины и ширины поля «живого» узора выглядит так:

class Tracery(size_t WIDTH, size_t HEIGHT)
{
	private
	{
		int[HEIGHT][WIDTH] world;
		int[HEIGHT][WIDTH] reserved;

		void backup()
		{
			for (int i = 0; i < WIDTH; i++)
			{
				for (int j = 0; j < HEIGHT; j++)
				{
					reserved[i][j] = world[i][j];
				}
			}
		}
	}

	this()
	{
		import std.random;
		auto rnd = Random(unpredictableSeed);

		for (int i = 0; i < WIDTH; i++)
		{
			for (int j = 0; j < HEIGHT; j++) { world[i][j] = cast(int) uniform(0, NUMBER_OF_COLORS, rnd); } } } auto opIndex(size_t i, size_t j) { return world[i][j]; } void opIndexAssign(int element, size_t i, size_t j) { world[i][j] = element; } auto execute() { backup; void transformCell(int i, int j) { auto up = ((j + 1) >= HEIGHT) ? HEIGHT - 1 : j + 1;
			auto down = ((j - 1) < 0) ? 0 : j - 1; auto right = ((i + 1) >= WIDTH) ?  WIDTH - 1 : i + 1;
			auto left = ((i - 1) < 0) ? 0 : i - 1;

			auto currentCell = reserved[i][j];
			auto nextCell = (currentCell + 1) % NUMBER_OF_COLORS;

			if (
					(reserved[i][up] == nextCell)   || 
					(reserved[i][down] == nextCell) || 
					(reserved[left][j] == nextCell) || 
					(reserved[right][j] == nextCell)
				)
			{
				world[i][j] = cast(int) nextCell;
			}
		}

		for (int i = 0; i < WIDTH; i++)
		{
			for (int j = 0; j < HEIGHT; j++)
			{
				transformCell(i, j);
			}
		}
	}


	void draw(QPainter painter, int cellWidth, int cellHeight)
	{
		Colors colors = new Colors(COLORS);
		auto palette = colors.getColors;
				
		QPen pen = new QPen;

	        for (int i = 0; i < WIDTH; i++)
		{
			for (int j = 0; j < HEIGHT; j++)
			{
				auto currentCell = world[i][j];

				QRect rect = new QRect;
	    		rect.setRect(i * cellWidth, j * cellHeight, cellWidth, cellHeight);

				painter.fillRect(rect, palette[currentCell]);			

				painter.setPen(pen);
				painter.drawRect(i * cellWidth, j * cellHeight, cellWidth, cellHeight);
			}
		}
	}
}

Изменения не слишком значительные, по сравнению с WireWorld, но я немного расскажу про некоторые.

Первое изменение коснулось конструктора класса — в конструкторе происходит инициализация массива случайными значениями в диапазоне от 0 до NUMBER_OF_COLORS (не включая последнее) и здесь использован стандартный проход по двумерному массиву вложенным циклом с задействованием шаблона uniform из std.random.

Cледующее изменение коснулось функции execute, которая выполняет высчисление одного шага изменения узора.

Во-первых, функция transformCell из WireWorld переехала из приватной области определения класса в область определения функции execute. Во-вторых, изменен сам алгоритм трансформации. Сам алгоритм трансформации описывается всего одной формулой, которая фигурирует в определении переменной nextCell: прибавляется единица к цвету текущей ячейки и берется остаток от деления на количество цветов палитры, что обеспечивает циклическую смену цвета в пределах ограниченного количества цветов. Также, алгоритм трансформации использует другую окрестность нежели WireWorld и соответственно другое правило, которое гласит, что если хотя бы одна клетка из окрестности текущей имеет «следующий» цвет, то текущая клетка перекрашивается. При этом определение окрестности осталось почти без изменений (убраны лишние четыре направления от текущей клетки). В-третьих, упростился сам алгоритм изменения состояния всех клеток поля — это простой вложенный цикл по массиву, который содержит вызов процедуру transformCell.

Изменения коснулись и функции отрисовки.

Из этой функции было убрано все лишнее, а именно, ручное определение цветов под раскрашивание — его заменило создание экземпляра класса Colors и выделение из него массива с предопределенными цветами. Таким образом, отрисовка сводится к простому проходу по внутреннему двумерному массиву, извлекающему значения для конкретных ячеек поля «живого узора» и использующему их в качестве индексов для ограниченной палитры (сохранена в переменную palette).

На этом работа с файлом cell.d завершена и переходим к описанию графического интерфейса программы.

Создаем в папке source файл gui.d и первым делом прописываем туда нужные импорты и определение переменной, которая будет хранить состояние поля «живого узора», а также «переходники» к событиям графического интерфейса:

module gui;

private
{
	import qte5;
	import cell;
}


Tracery!(265, 120) tracery;

extern(C)
{
    void onTimerTick(MainForm* mainFormPointer) 
    {
        (*mainFormPointer).runTimer;
    }

     void onStartButton(MainForm* mainFormPointer) 
    {
        (*mainFormPointer).runStart;
    }

    void onStopButton(MainForm* mainFormPointer) 
    {
        (*mainFormPointer).runStop;
    }
}

Как видно, «переходников» всего три: это событие для таймера, который негласно будет управлять эволюциями узора, событие для кнопки «Start» (кнопка запускающая процесс изменения узора) и событие для кнопки «Stop» (кнопка останавливающая изменения).

Определим теперь класс, который будет играть роль DrawBox’а, т.е будет описывать виджет выполняющий отображение элементов «живого узора»:

extern(C)
{
    void onDrawStep(RLiveTracery* traceryPointer, void* eventPointer, void* painterPointer) 
    { 
        (*traceryPointer).runDraw(eventPointer, painterPointer);
    }
}

class RLiveTracery : QWidget
{
    private
    {
        QWidget parent;
    }

    this(QWidget parent)
    {
        tracery = new Tracery!(265, 120);
        super(parent);
        this.parent = parent;
        setStyleSheet(`background : white`);
        setPaintEvent(&onDrawStep, aThis);
    }

    void runDraw(void* eventPointer, void* painterPointer)
    {
      
        QPainter painter = new QPainter('+', painterPointer);

        tracery.draw(painter, 5, 5);
       
        painter.end;
    }
}

Как видно, класс устроен очень просто: определено единственное событие runDraw, которое выполняет перерисовку содержимого виджета, используя метод draw (параметры 5 и 5 — это длина и ширина клеток, которые будут нарисованы).
Описание графического интерфейса практически полностью копирует аналогичный MainForm из проекта WireWorld:

// псевдонимы под Qt'шные типы
alias WindowType = QtE.WindowType;

// основное окно
class MainForm : QWidget
{
    private
    {
        QVBoxLayout mainBox;
        RLiveTracery box0;
        QPushButton button0, button1;
        QTimer timer;
        QAction action, action0, action1, action2;
    }

   
    this(QWidget parent, WindowType windowType) 
	{
		super(parent, windowType); 
		//resize(700, 500);
		setWindowTitle("LiveTracery");

        mainBox = new QVBoxLayout(this);

        box0 = new RLiveTracery(null);
        box0.saveThis(&box0);


        button0 = new QPushButton("Start", this);
        button1 = new QPushButton("Stop", this);
        
        timer = new QTimer(this);
        timer.setInterval(100); 


        action0 = new QAction(this, &onTimerTick, aThis);
        connects(timer, "timeout()", action0, "Slot()");

        action1 = new QAction(null, &onStartButton, aThis);
        action2 = new QAction(null, &onStopButton, aThis);
        
        connects(button0, "clicked()", action1, "Slot()");
        connects(button1, "clicked()", action2, "Slot()");
        connects(button0, "clicked()", timer, "start()");
        connects(button1, "clicked()", timer, "stop()");

        mainBox
            .addWidget(box0)
            .addWidget(button0)
            .addWidget(button1);

        setLayout(mainBox);
    }

    void runTimer()
    {
    	tracery.execute;
        box0.update;
    }

    void runStart()
    {
        button0.setEnabled(false);
        button1.setEnabled(true);
    }

    void runStop()
    {
        button0.setEnabled(true);
        button1.setEnabled(false);
    }
}

На этом работа с файлом gui.d закончена и финальным штрихом к проекту будет изменение файла app.d, который необходимо изменить следующим образом:

module main;

import core.runtime;
import qte5;
import gui;


mixin(QtE5EntryPoint!MainForm);

А теперь запускаем проект с помощью команды:

dub run

И видим примерно следующее:

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

Проект намеренно не усовершенствован, хотя, безусловно, многие изменения лежат на поверхности, и из соображения учебного примера, любые позитивные изменения мы оставляем тебе, подготовленный и искушенный в D, читатель

Добавить комментарий