Японские кроссворды на QtQuick

Череп и кости, КДПВ

Люблю в свободное время что-нибудь прототипировать. Это позволяет поизучать что-то новое. Данный прототип является клиентом для ресурса http://www.nonograms.ru/, разработчиком которого является Чугунный К.А/ KyberPrizrak /. Весь код доступен на GiHub. На стороне C++ работа с HTML, модель галереи. На стороне QtQuick визуализация.

В этот раз решил поковырять:

Под катом будет рассмотрено:


  • скриншоты;
  • как получить HTML без Qt WebKit;
  • как сделать кроссворд без Canvas.


Скриншоты

галлерея
Меню


Обходимся без Qt WebKit

Сайт отдает кроссворд в виде матрицы:

var d=[[571,955,325,492],
       [6,53,49,55],
       [47,18,55,65],
       ...]]

Дальше JS скрипы создают html код кроссворда. Модуль WebKit был помечен как deprecated. В замен него предлагается использовать модуль Web Engine основанный на проекте Chrome.

Тут сразу ждет небольшое разочарование. Web Engine не имеет API для работы с DOM на странице. Для разбора HTML кода пришлось воспользоваться сторонними средствами (Парсим HTML на C++ и Gumbo).
А вот загрузить страницу, отрендерить и получить нужный HTML мы можем.

QString getHtml(const QUrl& url)
{
    QWebEnginePage page;

    QEventLoop loop;
    QObject::connect(&page, &QWebEnginePage::loadFinished,
                     &loop, &QEventLoop::quit);
    page.load(url);
    loop.exec();

    QTimer::singleShot(1000, &loop, &QEventLoop::quit);
    QString html;

    page.toHtml([&html, &loop](const QString& data){
        html = data;
        loop.quit();
    });

    loop.exec();

    return html;
}

QTimer: singleShot здесь используется для ожидания когда страница достроится. Метод toHtml асинхронный и принимает в качестве входного параметра функцию обратного вызова, для получения результата.


Построение кроссворда

Представление кроссворда
Кроссворд решил представить как множество столбцов и строчек. Наверху красным обведено 10 столбцов, каждый размера 3. Слева обведены 10 строк, каждая размером 3. Далее код будет оперировать этими величинами.

Кроссворд можно сделать несколькими способами:


  • рисовать на C++;
  • рисовать на JS и Canvas;
  • построить из базовых элементов (Item, Rectangle, MouseArea и т.д.)

Я выбрал последний вариант.


Полный код
import QtQuick 2.5
import Qt.labs.controls 1.0

Item {
    clip:true
    property int margin: 20
    property int fontSize:  12
    property int ceilSize:  20;
    property int incCeilSize: ceilSize + 1
    property color borderColor: "#424242"

    property int rows:       0;
    property int rowSize:    0;

    property int column:     0;
    property int columnSize: 0;

    implicitHeight : crossGrid.height+margin*2
    implicitWidth : crossGrid.width+margin*2

    function loadFromNonogramsOrg(url) {
        console.log("Load:"+url);
        crossword.formNanogramsOrg(url);
    }

    function showOnlyNaturalNumber(val)
    {
        return val > 0 ? val: " ";
    }

    function drawCrossword(){
        var csize = crossword.size;
        if(csize.column() === 0 || csize.rows() === 0){
            return;
        }
        console.log(csize.column() + "x" + csize.rows());
        hRepeater.model = 0;
        rRepeater.model = 0;

        rowSize = crossword.rowSize();
        columnSize = crossword.columnSize();

        rows = csize.rows();
        column = csize.column();

        hRepeater.model = crossword.columnSize()*csize.column();
        rRepeater.model = crossword.rowSize()*csize.rows();
        bgImg.visible = true;
    }

    Image{
        id: bgImg
        asynchronous: true
        visible: false
        height: parent.height
        width: parent.width
        source:"qrc:/wall-paper.jpg"
    }

    Grid {
        id: crossGrid
        anchors.centerIn: parent
        columns: 2
        spacing: 2
        rowSpacing: 0
        columnSpacing: 0

        Rectangle{
            id:topLeftItm
            width: rowSize * ceilSize
            height:columnSize * ceilSize
            border.width: 1
            border.color: borderColor
            color: "transparent"
        }

        Grid {
            id: cGrid
            rows: columnSize
            columns: column

            Repeater {
                id: hRepeater
                model: 0
                Item {
                    width: ceilSize; height: ceilSize
                    property int rw : Math.floor(index/column)
                    property int cn : Math.floor(index%column)
                    property int prw: rw+1
                    property int pcm: cn+1

                    Rectangle{
                        height: (prw % 5 == 0) || (prw == columnSize) ? ceilSize : incCeilSize
                        width:  (pcm % 5 == 0)  ? ceilSize : incCeilSize
                        color: "transparent"
                        border.width: 1
                        border.color: borderColor

                        Text {
                            anchors.centerIn: parent
                            text:showOnlyNaturalNumber(
                                     crossword.columnValue(cn,rw));
                            font{
                                family: mandarinFont.name
                                pixelSize: fontSize
                            }
                        }

                    }
                }
            }
        }

        Grid {
            id: rGrid
            rows: rows
            columns: rowSize

            Repeater {
                id: rRepeater
                model: 0
                Item {
                    width: ceilSize; height: ceilSize
                    property int rw : Math.floor(index/rowSize)
                    property int cn : Math.floor(index%rowSize)
                    property int prw: rw+1
                    property int pcn: cn+1

                    Rectangle{
                        height: prw % 5 == 0 ? ceilSize : incCeilSize
                        width:  (pcn % 5 == 0) || (pcn == rowSize)
                                ? ceilSize : incCeilSize
                        color: "transparent"
                        border.width: 1
                        border.color: borderColor

                        Text {
                            anchors.centerIn: parent
                            text:showOnlyNaturalNumber(
                                     crossword.rowValue(rw,cn));
                            font{
                                family: mandarinFont.name
                                pixelSize: fontSize
                            }
                        }
                    }
                }
            }
        }

        Rectangle{
            id: playingField
            width: column * ceilSize
            height:rows   * ceilSize
            border.width: 1
            border.color: borderColor
            color: "transparent"

            Grid{
                rows: rows
                columns:column
                Repeater {
                    id: bRepeater
                    model: rows * column
                    Item {
                        id: ceilItm
                        width: ceilSize; height: ceilSize
                        property int rw : Math.floor(index/column)
                        property int cn : Math.floor(index%column)
                        state: "default"

                        Rectangle{
                            id: itmRec
                            height: (rw+1) % 5 == 0 ? ceilSize : incCeilSize
                            width: (cn+1) % 5 == 0  ? ceilSize : incCeilSize
                            color: "transparent"
                            border.width: 1
                            border.color: borderColor
                        }

                        Text{
                            id: itmTxt
                            visible:false
                            height: parent.height
                            width: parent.width
                            font.pixelSize: ceilSize
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment:   Text.AlignVCenter
                            text:"+"
                            rotation:45
                        }

                        MouseArea {
                            anchors.fill: parent
                            onClicked: {
                                if(parent.state == "default"){
                                    parent.state = "SHADED";
                                }else if(parent.state == "SHADED"){
                                    parent.state = "CLEAR";
                                }else{
                                    parent.state = "default";
                                }

                            }
                        }

                        states: [
                            State{
                                name:"SHADED"
                                PropertyChanges {
                                    target: itmRec; color: "black";
                                }
                                PropertyChanges {
                                    target: itmTxt; visible: false;
                                }
                            },
                            State{
                                name:"CLEAR"
                                PropertyChanges {
                                    target: itmRec; color: "transparent";
                                }
                                PropertyChanges {
                                    target: itmTxt; visible: true;
                                }
                            }
                        ]
                    }
                }
            }
        }
    }

    Text{
        visible: bgImg.visible
        anchors{
            right: parent.right
            rightMargin: 10
            bottom: parent.bottom
        }
        text:qsTr("Source: ")+"www.nonograms.ru"

        font{
            family: hanZiFont.name
            pixelSize: 12
        }
    }

    Connections {
        target: crossword
        onLoaded: {
            drawCrossword();
        }
    }
}

Основа представлена Item, размер которого вычисляется из размера crossGrid и размера отступа (margin)

Item {
    clip:true
    implicitHeight : crossGrid.height+margin*2
    implicitWidth : crossGrid.width+margin*2

    /* ... */

    Image{
        id: bgImg
        asynchronous: true
        visible: false
        height: parent.height
        width: parent.width
        source:"qrc:/wall-paper.jpg"
    }

    Grid {
        id: crossGrid
        anchors.centerIn: parent
        columns: 2
        spacing: 2

        /* ... */
    }
}

Элемент crossGrid

crossGrid

Grid {
    id: crossGrid
    anchors.centerIn: parent
    columns: 2
    spacing: 2
    rowSpacing: 16
    columnSpacing: 16

    Rectangle{
        id:topLeftItm
        color: "transparent"
        border.width: 1
        border.color: borderColor
        /* ... */
    }

    Grid {
        id: cGrid
        /* ... */
    }

    Grid {
        id: rGrid
        /* ... */
    }

    Rectangle{
        id: playingField
        /* ... */
    }
}

topLeftItm прямоугольник заполняющий пространство. cGrid и rGrid описывают сетку с числами. playingField поле для решения кроссворда.


Построение сетки

Если написать так:

Grid {
    id: cGrid
    rows: columnSize
    columns: column

    Repeater {
        id: hRepeater
        /* ... */
        Item {
            width: ceilSize; height: ceilSize
            Rectangle{
                height: ceilSize
                width: ceilSize
                color: "transparent"
                border.width: 1
                border.color: borderColor

                Text {
                    anchors.centerIn: parent
                    text: index
                        font{
                            family: mandarinFont.name
                            pixelSize: fontSize
                        }
                }
            }
        }
    }
}

то получим удвоение линии

удваение линии

Что бы убрать удвоение линии используем трюк с размерами Item и Rectangle. Размер Item фиксирован, для того что бы в повторителе (Repeater) все элементы располагались ровно. Rectangle шире и выше на единицу, в зависимости от необходимости двойной линии.

Repeater {
    id: hRepeater
    model: 0
    Item {
        width: ceilSize; height: ceilSize
        property int rw : Math.floor(index/column)
        property int cn : Math.floor(index%column)
        property int prw: rw+1
        property int pcm: cn+1

        Rectangle{
            height: (prw % 5 == 0) || (prw == columnSize) ? ceilSize : incCeilSize
            width:  (pcm % 5 == 0)  ? ceilSize : incCeilSize
            color: "transparent"
            border.width: 1
            border.color: borderColor

            Text {
                anchors.centerIn: parent
                text:showOnlyNaturalNumber(
                         crossword.columnValue(cn,rw));
                font{
                    family: mandarinFont.name
                    pixelSize: fontSize
                }
            }

        }
    }
}

Тут на основе индекса вычисляется строка (rw) и колонка (cn), увеличиваются на единицу, берется остаток от деления на 5. Т.е. через каждые 5 клеток ширина или высота Rectangle и Item совпадают, что дает удвоение линии.


Поле кроссворда

От поля нам нужна сетка и обработка щелчка мыши. Введем состояние ячейки сетки:


  • неактивная (default);
  • закрашенная (SHADED);
  • помеченная пустой (CLEAR).

Начинать будем c неактивного состояния и менять по клику мыши в следующей последовательности
Граф состояний

Код рисования ячейки:

Item {
    id: ceilItm
    width: ceilSize; height: ceilSize
    property int rw : Math.floor(index/column)
    property int cn : Math.floor(index%column)
    state: "default"

    Rectangle{
        id: itmRec
        height: (rw+1) % 5 == 0 ? ceilSize : incCeilSize
        width: (cn+1) % 5 == 0  ? ceilSize : incCeilSize
        color: "transparent"
        border.width: 1
        border.color: borderColor
    }

    Text{
        id: itmTxt
        visible:false
        height: parent.height
        width: parent.width
        font.pixelSize: ceilSize
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment:   Text.AlignVCenter
        text:"+"
        rotation:45
    }

    MouseArea {
        anchors.fill: parent
        onClicked: {
            if(parent.state == "default"){
                parent.state = "SHADED";
            }else if(parent.state == "SHADED"){
                parent.state = "CLEAR";
            }else{
                parent.state = "default";
            }

        }
    }

    states: [
        State{
            name:"SHADED"
            PropertyChanges {
                target: itmRec; color: "black";
            }
            PropertyChanges {
                target: itmTxt; visible: false;
            }
        },
        State{
            name:"CLEAR"
            PropertyChanges {
                target: itmRec; color: "transparent";
            }
            PropertyChanges {
                target: itmTxt; visible: true;
            }
        }
    ]
}

itmTxt элемент добавляющий крестик на ячейку, отображая её как помеченную пустой. Тут вовсю используется возможность описывать различные состояния через states.
MouseArea осуществляет переход. То из-за чего все затевалось. Никаких расчетов (преобразования координаты мыши в ячейку сетки), никаких ручных перерисовок.

© Habrahabr.ru