Пишем Pixel Art Maker на чистом JavaScript

Доброго времени суток, друзья!

Предисловие

Однажды веб серфинг привел меня к этому.

Позже обнаружил статью про то, как это работает.

Казалось бы, ничего особенного — Пикачу, нарисованный средствами CSS. Данная техника называется Pixel Art (пиксельное искусство?). Что меня поразило, так это трудоемкость процесса. Каждая клеточка раскрашивается вручную (ну, почти; благо существуют препроцессоры; Sass в данном случае). Конечно, красота требует жертв. Однако разработчик — существо ленивое. Посему я задумался об автоматизации. Так появилось то, что я назвал Pixel Art Maker.

Условия

Что мы хотим получить?

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

Вот парочка примеров из сети:

Дополнительные функции:

  • форма клеточек — квадрат или круг
  • ширина клеточек в пикселях
  • количество клеточек
  • цвет фона
  • цвет для раскрашивания
  • функция создания холста
  • функция отображения номеров клеточек
  • функция сохранения/удаления изображения
  • функция очистки холста
  • функция удаления холста

Более мелкие детали обсудим в процессе кодинга.

Итак, поехали.

Разметка

Для реализации необходимого функционала наш HTML должен выглядеть примерно так:

<!-- создаем контейнер для инструментов -->
<div class="tools">

    <!-- создаем контейнер для формы фигур (клеточек) -->
    <div>
        <p>Shape Form</p>
        <select>
            <!-- квадрат -->
            <option value="squares">Square</option>

            <!-- круг -->
            <option value="circles">Circle</option>
        </select>
    </div>

    <!-- создаем контейнер для ширины и количества клеточек -->
    <div class="numbers">
        <!-- ширина -->
        <div>
            <!-- устанавливаем диапазон от 10 до 50 (объяснение ниже) -->
            <p>Shape Width <br> <span>(from 10 to 50)</span></p>
            <input type="number" value="20" class="shapeWidth">
        </div>

        <!-- количество -->
        <div>
            <!-- устанавливаем аналогичный диапазон -->
            <p>Shape Number <br> <span>(from 10 to 50)</span></p>
            <input type="number" value="30" class="shapeNumber">
        </div>
    </div>

    <!-- создаем контейнер для цветов -->
    <div class="colors">
        <!-- цвет фона -->
        <div>
            <p>Background Color</p>
            <input type="color" value="#ffff00" required class="backColor">
        </div>

        <!-- цвет фигуры (для раскрашивания) -->
        <div>
            <p>Shape Color</p>
            <input type="color" value="#0000ff" class="shapeColor">
        </div>
    </div>

    <!-- создаем контейнер для кнопок -->
    <div class="buttons">
        <!-- кнопка для создания холста -->
        <input type="button" value="Generate Canvas" class="generate">

        <!-- кнопка для показа/скрытия номеров клеточек (фигур) -->
        <input type="button" value="Show/Hide Numbers" class="show">

        <!-- кнопка сохранения/удаления изображения (результата) -->
        <input type="button" value="Save/Delete Image" class="save">

        <!-- кнопка для очистки холста с сохранением ширины и количества фигур -->
        <input type="button" value="Clear Canvas" class="clear">

        <!-- кнопка для полного удаления холста -->
        <input type="button" value="Delete Canvas" class="delete">
    </div>
</div>

<!-- холст -->
<canvas></canvas>

Диапазон (лимит) значений для ширины и количества клеточек определялся опытным путем. Эксперименты показали, что меньшие/большие значения нецелесообразны по причинам чрезмерной детализации (для значений < 10 для ширины), снижения производительности (для значений > 50 для количества) и т.д.

Стили

В стилях у нас ничего особенного.

CSS:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    margin: 0;
    min-height: 100vh;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    align-content: flex-start;
}

h1 {
    width: 100%;
    text-align: center;
    font-size: 2.4em;
    color: #222;
}

.tools {
    height: 100%;
    display: inherit;
    flex-direction: column;
    margin: 0;
    font-size: 1.1em;
}

.buttons {
    display: inherit;
    flex-direction: column;
    align-items: center;
}

div {
    margin: .25em;
    text-align: center;
}

p {
    margin: .25em 0;
    user-select: none;
}

select {
    padding: .25em .5em;
    font-size: .8em;
}

input,
select {
    outline: none;
    cursor: pointer;
}

input[type="number"] {
    width: 30%;
    padding: .25em 0;
    text-align: center;
    font-size: .8em;
}

input[type="color"] {
    width: 30px;
    height: 30px;
}

.buttons input {
    width: 80%;
    padding: .5em;
    margin-bottom: .5em;
    font-size: .8em;
}

.examples {
    position: absolute;
    top: 0;
    right: 0;
}

a {
    display: block;
}

span {
    font-size: .8em;
}

canvas {
    display: none;
    margin: 1em;
    cursor: pointer;
    box-shadow: 0 0 1px #222;
}

JavaScript

Определяем холст и его контекст (2D контекст рисования):

let c = document.querySelector('canvas'),
    $ = c.getContext('2d')

Находим кнопку для создания холста и «вешаем» на нее обработчик события «клик»:

document.querySelector('.generate').onclick = generateCanvas

Весь дальнейший код будет находиться в функции «generateCanvas»:

function generateCanvas(){
    ...
}

Определяем форму, ширину, количество по горизонтали и общее количество (холст представляет собой одинаковое количество клеточек по горизонтали и вертикали), а также цвет фона:

// форма
let shapeForm = document.querySelector('select').value
// ширина (только целые числа)
let shapeWidth = parseInt(document.querySelector('.shapeWidth').value)
// количество по горизонтали (только целые числа)
let shapeNumber = parseInt(document.querySelector('.shapeNumber').value)
// общее количество (удваиваем количество по горизонтали)
let shapeAmount = Math.pow(shapeNumber, 2)
// цвет фона
let backColor = document.querySelector('.backColor').value

Определяем размер холста и устанавливаем ему соответствующие атрибуты (помним, что правильный размер холста устанавливается через атрибуты):

// ширина = высота = ширина клеточки * количество клеточек по горизонтали
let W = H = shapeWidth * shapeNumber
c.setAttribute('width', W)
c.setAttribute('height', H)

Некоторые дополнительные настройки:

// ширина границ
let border = 1
// цвет границ
let borderColor = 'rgba(0,0,0,.4)'
// по умолчанию номера фигур не отображаются
let isShown = false

// проверяем соблюдение диапазона значений
// и числового формата данных
// отображаем холст
// и в зависимости от формы фигуры запускаем соответствующую функцию
if (shapeWidth < 10 || shapeWidth > 50 || shapeNumber < 10 || shapeNumber > 50 || isNaN(shapeWidth) || isNaN(shapeNumber)) {
    throw new Error(alert('wrong number'))
} else if (shapeForm == 'squares') {
    c.style.display = 'block'
    squares()
} else {
    c.style.display = 'block'
    circles()
}

Вот как выглядит функция «squares»:

function squares() {
    // определяем начальные координаты
    let x = y = 0

    // массив фигур
    let squares = []

    // ширина и высота фигуры (квадрата)
    let w = h = shapeWidth

    // формируем необходимое количество фигур
    addSquares()

    // функция-конструктор
    function Square(x, y) {
        // координата х 
        this.x = x
        // координата y 
        this.y = y
        // цвет фигуры = цвет фона
        this.color = backColor
        // по умолчанию фигура не выбрана
        this.isSelected = false
    }

    // функция добавления фигур
    function addSquares() {
        // цикл по общему количеству фигур
        for (let i = 0; i < shapeAmount; i++) {
            // используем конструктор
            let square = new Square(x, y)

            // определяем координаты каждой фигуры
            // для этого к значению х прибавляем ширину фигуры
            x += w

            // когда значение х становится равным ширине холста
            // увеличиваем значение y на высоту фигуры
            // так осуществляется переход к следующей строке
            // сбрасываем значение х
            if (x == W) {
                y += h
                x = 0
            }

            // добавляем фигуру в массив
            squares.push(square)
        }
        // рисуем фигуры на холсте
        drawSquares()
    }

    // функция рисования фигур
    function drawSquares() {
        // очищаем холст
        $.clearRect(0, 0, W, H)

        // цикл по количеству фигур
        for (let i = 0; i < squares.length; i++) {
            // берем фигуру из массива
            let square = squares[i]
            // начинаем рисовать
            $.beginPath()
            // рисуем квадрат, используя координаты фигуры
            $.rect(square.x, square.y, w, h)
            // цвет фигуры
            $.fillStyle = square.color
            // ширина границ
            $.lineWidth = border
            // цвет границ
            $.strokeStyle = borderColor
            // заливаем фигуру
            $.fill()
            // обводим фигуру
            $.stroke()

            // если нажата кнопка для отображения номеров фигур
            if (isShown) {
                $.beginPath()
                // параметры шрифта
                $.font = '8pt Calibri'
                // цвет текста
                $.fillStyle = 'rgba(0,0,0,.6)'
                // рисуем номер, опираясь на его координаты
                $.fillText(i + 1, square.x, (square.y + 8))
            }
        }
    }

    // вешаем на холст обработчик события "клик"
    c.onclick = select
    // функция обработки клика
    function select(e) {
        // определяем координаты курсора
        let clickX = e.pageX - c.offsetLeft,
            clickY = e.pageY - c.offsetTop

        // цикл по количеству фигур
        for (let i = 0; i < squares.length; i++) {
            let square = squares[i]

            // определяем фигуру, по которой кликнули
            // пришлось повозиться
            // возможно, существует более изящное решение
            if (clickX > square.x && clickX < (square.x + w) && clickY > square.y && clickY < (square.y + h)) {
                // раскрашиваем фигуру, по которой кликнули, заданным цветом
                // при повторном клике возвращаем фигуре первоначальный цвет (цвет фона)
                if (square.isSelected == false) {
                    square.isSelected = true
                    square.color = document.querySelector('.shapeColor').value
                } else {
                    square.isSelected = false
                    square.color = backColor
                }
                // перерисовываем фигуры
                // в принципе, можно реализовать перерисовку только фигуры, по которой кликнули
                // но решение, по крайней мере у меня, получилось громоздким
                // решил, что игра не стоит свеч 
                drawSquares()
            }
        }
    }

    // находим кнопку для отображения номеров фигур и вешаем на нее обработчик события "клик"
    document.querySelector('.show').onclick = showNumbers
    // функция отображения номеров фигур
    function showNumbers() {
        if (!isShown) {
            isShown = true
            // цикл по количеству фигур 
            for (let i = 0; i < squares.length; i++) {
                let square = squares[i]
                $.beginPath()
                // параметры шрифта 
                $.font = '8pt Calibri'
                // цвет шрифта 
                $.fillStyle = 'rgba(0,0,0,.6)'
                // рисуем номер, опираясь на его координаты
                $.fillText(i + 1, square.x, (square.y + 8))
            }
        } else {
            isShown = false
        }
        // перерисовываем фигуры
        drawSquares()
    }
}

Функция «circles» очень похожа на функцию «squares».

JavaScript:

function circles() {
    // радиус круга
    let r = shapeWidth / 2

    let x = y = r

    let circles = []

    addCircles()

    function Circle(x, y) {
        this.x = x
        this.y = y
        this.color = backColor
        this.isSelected = false
    }

    function addCircles() {
        for (let i = 0; i < shapeAmount; i++) {
            let circle = new Circle(x, y)
            // к значению х прибавляется ширина фигуры
            x += shapeWidth
            // когда значение х становится равным сумме ширины холста и радиуса фигуры
            // увеличиваем значение у на ширину фигуры
            // сбрасываем значение х до значения радиуса
            if (x == W + r) {
                y += shapeWidth
                x = r
            }
            circles.push(circle)
        }
        drawCircles()
    }

    function drawCircles() {
        $.clearRect(0, 0, W, H)

        for (let i = 0; i < circles.length; i++) {
            let circle = circles[i]
            $.beginPath()
            // рисуем круг
            $.arc(circle.x, circle.y, r, 0, Math.PI * 2)
            $.fillStyle = circle.color
            $.strokeStyle = borderColor
            $.lineWidth = border
            $.fill()
            $.stroke()
            if (isShown) {
                $.beginPath()
                $.font = '8pt Calibri'
                $.fillStyle = 'rgba(0,0,0,.6)'
                $.fillText(i + 1, (circle.x - 8), circle.y)
            }
        }
    }

    c.onclick = select
    function select(e) {
        let clickX = e.pageX - c.offsetLeft,
            clickY = e.pageY - c.offsetTop

        for (let i = 0; i < circles.length; i++) {
            let circle = circles[i]

            // определяем круг, по которому кликнули
            let distanceFromCenter = Math.sqrt(Math.pow(circle.x - clickX, 2) + Math.pow(circle.y - clickY, 2))

            if (distanceFromCenter <= r) {
                if (circle.isSelected == false) {
                    circle.isSelected = true
                    circle.color = document.querySelector('.shapeColor').value
                } else {
                    circle.isSelected = false
                    circle.color = backColor
                }
                drawCircles()
            }
        }
    }

    document.querySelector('.show').onclick = showNumbers
    function showNumbers() {
        if (!isShown) {
            isShown = true
            for (let i = 0; i < circles.length; i++) {
                let circle = circles[i]
                $.beginPath()
                $.font = '8pt Calibri'
                $.fillStyle = 'rgba(0,0,0,.6)'
                $.fillText(i + 1, (circle.x - 8), circle.y)
            }
        } else {
            isShown = false
        }
        drawCircles()
    }
}

Находим кнопку для сохранения/удаления результата (изображения) и вешаем на нее обработчик события «клик»:

document.querySelector('.save').onclick = () => {
    // ищем изображение
    let img = document.querySelector('img')

    // если не находим, создаем
    // если находим, удаляем
    img == null ? document.body.appendChild(document.createElement('img')).src = c.toDataURL() : document.body.removeChild(img)
}

Находим кнопку для очистки холста и…:

document.querySelector('.clear').onclick = () => {
    // очищаем и перерисовываем холст
    $.clearRect(0, 0, W, H)
    generateCanvas()
}

Находим кнопку для удаления холста и…:

document.querySelector('.delete').onclick = () => {
    $.clearRect(0, 0, W, H)
    c.style.display = 'none'
}

Результат выглядит так:

Codepen (добавил парочку примеров использования)

Github

Благодарю за внимание.

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр