Первое возвращение в «мир проводов»

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

Но сегодня, я приглашаю вас, дорогой мой читатель, в первое возвращение в «мир проводов», в котором мы постараемся загладить нашу вину и немного усовершенствовать WireWorld.

Для начала создадим новый проект dub и скопируем в папку source проекта файл qte5.d:

dub init wireworld2

Теперь, можно приступать к работе, для чего создадим файл wireworld.d, в который мы поместим некоторые определения мира клеточного автомата WireWorld, которые возьмем из статьи о WireWorld, но немного исправим метод drawWorld, чтобы немного поменять цветовую палитру клеточного автомата и отразить ряд изменений в API QtE5, которые произошли после выхода нашей реализации:

	// нарисовать мир с помощью QtE5
	void drawWorld(QPainter painter, int cellWidth, int cellHeight)
	{

	    QColor EmptyColor = new QColor(null);
	    QColor HeadColor = new QColor(null);
	    QColor TailColor = new QColor(null);
	    QColor ConductorColor = new QColor(null);
	    QColor CornerColor = new QColor(null);

		EmptyColor.setRgb(8, 8, 8, 230);
		HeadColor.setRgb(175, 32, 202, 230);
		TailColor.setRgb(88, 146, 210, 230);
		ConductorColor.setRgb(153, 153, 76, 230);
		
		CornerColor.setRgb(133, 133, 133, 230);

		QPen pen = new QPen;
		pen.setColor(CornerColor);

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

				// рисование прямоугольника
				QRect rect = new QRect;
	    		rect.setRect(i * cellWidth, j * cellHeight, cellWidth, cellHeight);

				final switch (currentCell) with (Element)
				{
					case Empty:
						painter.fillRect(rect, EmptyColor);			
						break;
					case Head:
						painter.fillRect(rect, HeadColor);
						break;
					case Tail:
						painter.fillRect(rect, TailColor);
						break;
					case Conductor:
						painter.fillRect(rect, ConductorColor);
						break;
				}

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

На этом изменения в файле wireworld.d окончены, поэтому закрываем его и создаем новый файл gui.d в папке sources.

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

В новом графическом интерфейсе мы также будем использовать самописный виджет QWireWorld, несколько кнопок (уже знакомые нам Load world…, Start и Stop, к которым добавим кнопку Save world…) и бокс, который позволит нам выбрать тип элемента Wireworld и с помощью которого возможно будет нарисовать этот элемент внутри виджета QWireWorld:


Для осуществления размещения элементов графического интерфейса воспользуемся элементом QGridLayout, а все остальное (кроме нового кода) возьмем из статьи про WireWorld:

module gui;

import std.algorithm;
import std.range;

import qte5;

import wireworld;

// состояние мира
enum WORLD_WIDTH  = 220;
enum WORLD_HEIGHT = 200;
enum CELL_WIDTH   = 5;
enum CELL_HEIGHT  = 5;

WireWorld!(WORLD_WIDTH, WORLD_HEIGHT) wireWorld;

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

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

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

    void onLoadButton(MainForm* mainFormPointer) 
    {
        (*mainFormPointer).runLoad;
    }

    void onSaveButton(MainForm* mainFormPointer) 
    {
        (*mainFormPointer).runSave;
    }
}

extern(C)
{
    void onDrawStep(QWireWorld* wireWorldPointer, void* eventPointer, void* painterPointer) 
    { 
        (*wireWorldPointer).runDraw(eventPointer, painterPointer);
    }

    void onMousePressEvent(QWireWorld* wireWorldPointer, void* eventPointer) 
    {
		(*wireWorldPointer).runMouseEvent(eventPointer);
	}
}

class QWireWorld : QWidget
{
    private
    {
        QWidget parent;
    }

    this(QWidget parent)
    {
        wireWorld = new WireWorld!(WORLD_WIDTH, WORLD_HEIGHT);
        super(parent);
        this.parent = parent;
        setPaintEvent(&onDrawStep, aThis);
    }

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

        wireWorld.drawWorld(painter, CELL_WIDTH, CELL_HEIGHT);
       
        painter.end;
    }

    void runMouseEvent(void* eventPointer)
    {
        QMouseEvent qe = new QMouseEvent('+', eventPointer);
        
        auto X = qe.x;
        auto Y = qe.y;
        auto rX = -1;
        auto rY = -1;

        foreach (i; 0..WORLD_WIDTH)
        {
            auto x = i * CELL_WIDTH;
            auto xx = x + CELL_WIDTH;

            if ((x >= X) && (X <= xx)) { rX = i; break; } } foreach (i; 0..WORLD_HEIGHT) { auto y = i * CELL_HEIGHT; auto yy = y + CELL_HEIGHT; if ((y > Y) && (Y < yy)) { rY = i; break; } } rX -= 1; rY -= 1; if ((rX != -1) && (rY != -1)) { switch ((cast(MainForm) parent).get) { case "Empty": wireWorld[rX, rY] = Element.Empty; break; case "Head": wireWorld[rX, rY] = Element.Head; break; case "Tail": wireWorld[rX, rY] = Element.Tail; break; case "Conductor": wireWorld[rX, rY] = Element.Conductor; break; default: break; } this.update; } } } // псевдонимы под Qt'шные типы alias WindowType = QtE.WindowType; // основное окно class MainForm : QWidget { private { QHBoxLayout mainBox; QVBoxLayout vbox0, vbox1, vbox2; QGridLayout grid0; QGroupBox group0, group1, group2; QWireWorld box0; QPushButton button, button0, button1, button2; QTimer timer; QAction action, action0, action1, action2, action3; } protected QComboBox combo0; this(QWidget parent, WindowType windowType) { super(parent, windowType); showMaximized; setWindowTitle("WireWorld2"); mainBox = new QHBoxLayout(null); vbox0 = new QVBoxLayout(null); box0 = new QWireWorld(this); box0.saveThis(&box0); box0.setMousePressEvent(&onMousePressEvent, box0.aThis()); vbox0.addWidget(box0); group0 = new QGroupBox(null); group0.setFixedWidth(1050); group0.setText("Wireworld"); group0.setLayout(vbox0); vbox1 = new QVBoxLayout(null); combo0 = new QComboBox(null); [ "Empty", "Head", "Tail", "Conductor" ] .enumerate(0) .each!(a => combo0.addItem(a[1], a[0]));

        vbox1.addWidget(combo0);

        group1 = new QGroupBox(null);
        group1.setFixedWidth(225);
        group1.setFixedHeight(70);
        group1.setText("Wireworld elements");
		group1.setLayout(vbox1);

        grid0 = new QGridLayout(null);

       	button = new QPushButton("Load world...", this);
        button0 = new QPushButton("Start", this);
        button1 = new QPushButton("Stop", this);
        button2 = new QPushButton("Save world...", this);
        
        timer = new QTimer(this);
        timer.setInterval(100); 

        action  = new QAction(this, &onLoadButton, aThis);
        action0 = new QAction(this, &onTimerTick, aThis);
        action1 = new QAction(this, &onStartButton, aThis);
        action2 = new QAction(this, &onStopButton, aThis);
        action3 = new QAction(this, &onSaveButton, aThis);
        
        connects(timer, "timeout()", action0, "Slot()");
        connects(button, "clicked()", action, "Slot()");
        connects(button0, "clicked()", action1, "Slot()");
        connects(button1, "clicked()", action2, "Slot()");
        connects(button0, "clicked()", timer, "start()");
        connects(button1, "clicked()", timer, "stop()");
        connects(button2, "clicked()", action3, "Slot()");
        
        grid0
            .addWidget(button, 0, 0)
            .addWidget(button2, 0, 1)
            .addWidget(button0, 1, 0)
            .addWidget(button1, 1, 1);

        group2 = new QGroupBox(null);
        group2.setFixedWidth(225);
        group2.setFixedHeight(100);
        group2.setText("Wireworld control");
		group2.setLayout(grid0);

        vbox2 = new QVBoxLayout(null);
        vbox2
            .addWidget(group1)
            .addWidget(group2)
            .addWidget(new QWidget(null));
        
        mainBox
            .addWidget(group0)
            .addLayout(vbox2);

        setLayout(mainBox);
    }

    @property auto get()
    {
        return combo0.text!string;
    }

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

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

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

    void runLoad()
    {
       import std.stdio;
       import std.conv;
       import std.file;
       import std.string;
       
       QFileDialog fileDialog = new QFileDialog('+', null);
       string filename = fileDialog.getOpenFileNameSt("Open WireWorld File", "", "*.wwd *.txt");   	

       if (filename != "")
       {
            auto content = (cast(string) std.file.read(filename)).replace("\n", "");

            foreach (i, e; content)
            {
                auto x = i / WORLD_WIDTH;
                auto y = i % WORLD_HEIGHT;
                // перевод символа в число (код 0 в ASCII = 48
                auto k = to!int(e) - 48;

                switch (k)
                {
                        case 0:
                                wireWorld[x, y] = Element.Empty;
                                break;
                        case 1:
                                wireWorld[x, y] = Element.Head;
                                break;
                        case 2:
                                wireWorld[x, y] = Element.Tail;
                                break;
                        case 3:
                                wireWorld[x, y] = Element.Conductor;
                                break;
                        default:
                                break;
                }
            }
       }
    }

    void runSave()
    {
        import std.stdio;
        
        QFileDialog fileDialog = new QFileDialog('+', null);
        string filename = fileDialog.getSaveFileNameSt("Save WireWorld File", "", "*.wwd *.txt");
        
        if (filename != "")
        {
            File file;
            file.open(filename, "w");

            foreach (x; 0..WORLD_WIDTH)
            {
                foreach (y; 0..WORLD_HEIGHT)
                {
                    file.write(wireWorld[x, y]);
                }
                file.writeln;
            }
        }
    }
}

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

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

Также, я решил изменить процедуру загрузки файла с «сохранением мира», а поэтому передалал формат файла.

Теперь, файл с сохраненным состоянием клеточного автомата представляет собой не CSV-файл, который редактируется c помощью Microsoft Office Excel или LibreOffice Calc, а просто набор строк, состоящих из цифр от 0 до 3. Длина строки равна ширине массива, который содержит в себе поле для WireWorld (задается в переменной-перечислении WORLD_HEIGHT), а количество строк в файле равно длине массива (задается в переменной-перечислении WORLD_WIDTH). Числа в строках соотвествуют численным значениям перечисления Element: 0 — пустое пространство, 1 — голова электрона, 2 — хвост электрона и 3 — проводник.

Файл с таким форматом мира может иметь расширение *.wwd (WireWorlD) или *.txt (обычный текстовой файл, но с соблюдением описанного формата).

Для загрузки/сохранения файла служат две процедуры runLoad и runSave, которые являются простыми способами организации линейной загрузки/сохранения данных файла:

    void runLoad()
    {
       import std.stdio;
       import std.conv;
       import std.file;
       import std.string;
       
       QFileDialog fileDialog = new QFileDialog('+', null);
       string filename = fileDialog.getOpenFileNameSt("Open WireWorld File", "", "*.wwd *.txt");   	

       if (filename != "")
       {
            auto content = (cast(string) std.file.read(filename)).replace("\n", "");

            foreach (i, e; content)
            {
                auto x = i / WORLD_WIDTH;
                auto y = i % WORLD_HEIGHT;
                // перевод символа в число (код 0 в ASCII = 48
                auto k = to!int(e) - 48;

                switch (k)
                {
                        case 0:
                                wireWorld[x, y] = Element.Empty;
                                break;
                        case 1:
                                wireWorld[x, y] = Element.Head;
                                break;
                        case 2:
                                wireWorld[x, y] = Element.Tail;
                                break;
                        case 3:
                                wireWorld[x, y] = Element.Conductor;
                                break;
                        default:
                                break;
                }
            }
       }
    }

    void runSave()
    {
        import std.stdio;
        
        QFileDialog fileDialog = new QFileDialog('+', null);
        string filename = fileDialog.getSaveFileNameSt("Save WireWorld File", "", "*.wwd *.txt");
        
        if (filename != "")
        {
            File file;
            file.open(filename, "w");

            foreach (x; 0..WORLD_WIDTH)
            {
                foreach (y; 0..WORLD_HEIGHT)
                {
                    file.write(wireWorld[x, y]);
                }
                file.writeln;
            }
        }
    }
}

После этого можно со спокойной душой перейти к самой волнующей части проекта: к доработке виджета отображения мира WireWorld.

Чтобы осуществить доработку копируем описание виджета из статьи про «мир проводов» и добавляем в блок extern(C) новый «переходник» для процедуры runMouseEvent:

extern(C)
{
    void onDrawStep(QWireWorld* wireWorldPointer, void* eventPointer, void* painterPointer) 
    { 
        (*wireWorldPointer).runDraw(eventPointer, painterPointer);
    }

    void onMousePressEvent(QWireWorld* wireWorldPointer, void* eventPointer) 
    {
		(*wireWorldPointer).runMouseEvent(eventPointer);
	}
}

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

enum WORLD_WIDTH  = 220;
enum WORLD_HEIGHT = 200;
enum CELL_WIDTH   = 5;
enum CELL_HEIGHT  = 5;

Процедура runMouseEvent выглядит следующим образом:

    void runMouseEvent(void* eventPointer)
    {
        QMouseEvent qe = new QMouseEvent('+', eventPointer);
        
        auto X = qe.x;
        auto Y = qe.y;
        auto rX = -1;
        auto rY = -1;

        foreach (i; 0..WORLD_WIDTH)
        {
            auto x = i * CELL_WIDTH;
            auto xx = x + CELL_WIDTH;

            if ((x >= X) && (X <= xx)) { rX = i; break; } } foreach (i; 0..WORLD_HEIGHT) { auto y = i * CELL_HEIGHT; auto yy = y + CELL_HEIGHT; if ((y > Y) && (Y < yy))
            {
                rY = i;
                break;
            }
        }

        rX -= 1;
        rY -= 1;

        if ((rX != -1) && (rY != -1))
        {
            switch ((cast(MainForm) parent).get)
            {
                case "Empty":
                    wireWorld[rX, rY] = Element.Empty;
                    break;
                case "Head":
                    wireWorld[rX, rY] = Element.Head;
                    break;
                case "Tail":
                    wireWorld[rX, rY] = Element.Tail;
                    break;
                case "Conductor":
                    wireWorld[rX, rY] = Element.Conductor;
                    break;
                default:
                    break;
            }
            this.update;
        }
    }
}

В данном случае, сначала создается объект события QMouseEvent, который содержит в себе необходимую нам информацию о том, где находиться курсор мыши.

Эта информация нам нужна для того, чтобы осуществить возможность рисования своих проектов прямо в нашей программе, минуя посредничество сторонних инструментов, вроде тех, что описаны были в первой реализации. Рисование (и собственно работа этого кода) в этом случае будет выглядеть так: с помощью QMouseEvent получаем координаты X и Y курсора мыши, после чего с помощью простого перебора номеров клеток в строках и столбцах, а также неравенств прямоугольника (для этого нам и были нужны критические переменные) определяем координаты курсора мыши в «условных клетках» (т.е определяем координаты ячейки на которую указывает курсор мыши в терминах индексов массива, содержащего весь мир WireWorld).

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

Помимо этого, во избежание ложных срабатываний или неправильного определения координат «в клетках» введен страховочный механизм, который проверяет правильно ли они рассчитаны (идет сравнение с -1 каждой координаты), и только если координаты отличаются от предустановленных страховочных значений, произойдет изменение типа клетки в массиве.

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

module main;
import core.runtime;
import qte5;
import gui;


mixin(QtE5EntryPoint!MainForm);

Сохраняем файл и запускаем проект с помощью dub:

dub run

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


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

Код проекта и тестовый файл сохраненного состояния доступен на GitHub.

aquaratixc

Программист-самоучка и программист-любитель

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