Всем привет! Идея для этой статьи пришла еще месяц назад, но в силу занятости на работе времени катастрофически не хватало. Однажды вечером в YouTube я наткнулся на ролик о создании игры-платформера в стиле пиксельной графики. И тут мне вспомнились мои первые уроки информатики в школе, где мы "рисовали на Бейсике" и играли в "ворона ест буквы".

Предисловие

На дворе стоял 2000-й год. Кризис 98 года остался позади. Я учился в 8 классе местной школы, в небольшом городке. С началом учебного года всех ждало небольшое событие - ввели урок информатики. Многие отнеслись к этому, как к еще одному предмету который надо учить, но были и те, у кого загорелись глаза. В числе последних оказался и я.

Надо отметить, что информатику хоть и ввели, но "ввести новые компьютеры" забыли, потому что денег на эти цели не было. На службе у нашей школы тогда стояли машины made in USSR - "Электроника МС 0511" и несколько их чуть более современных аналогов. Работали они только по им самим ведомым законам, или после прихода некоего "Николая Владимировича" - местного мастера.

фото с сайта - red-innovations.su
фото с сайта - red-innovations.su

Вести предмет как водится поставили молодого и "горячего" преподавателя - девушку 26 лет, которая кстати очень старалась. Мы учили системы счисления и переводили письменно числа из одной в другую. Читали про общее устройство ПК и конечно был Бейсик. У каждого тетрадка была в прочной прозрачной обложке, сзади которой была нарисована система координат. Это был своего рода холст для эскизов фигур, которые мы потом старательно переносили в код.

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

Рисуем первое изображение

Для своих целей я взял BufferedImage. Начал с простой функции, которая рисует пиксель в заданных координатах и с определенным цветом.

fun drawPixel(
    x:Int, y:Int, red:Int, green:Int, blue: Int,
    image: BufferedImage
) {
    image.setRGB(x, y, Color(red,green,blue).rgb)
}

Чтобы проверить работу набросал метод, который выводит картинку с пикселями рандомного цвета. В функции можно понизить значение каждого из каналов цвета, задав диапазон - красного redRng, зеленого greenRng и синего blueRng цвета.

fun drawRandImage( 
    image: BufferedImage, stepSize: Int = 1,  
    redRng: Int = 255, greenRng: Int = 255, blueRng: Int = 255
) { 
    for(posX in 0 until image.width step stepSize){ 
        for (posY in 0 until image.height step stepSize) {
            val r = if (redRng <= 0) 0 else Random.nextInt(0, redRng) 
            val g = if (greenRng <= 0) 0 else Random.nextInt(0, greenRng)
            val b = if (blueRng <= 0) 0 else Random.nextInt(0, blueRng) 
            drawPixel(posX, posY, r, g, b, image) 
        }  
    }
}

Если поставить в цикле шаг stepSize отличный от единицы и занизить один из каналов, то можно получить интересный эффект.

рандомное изображение 1.) step 3, RGB (11, 238, 229)   2.) step 2, RGB (181, 19, 227)
рандомное изображение 1.) step 3, RGB (11, 238, 229) 2.) step 2, RGB (181, 19, 227)

Вроде что-то вырисовывается. Теперь надо сохранить результат. Роль по записи изображения была героически возложена на ImageIO. Насколько я знаю - он блокирующий, поэтому я его от греха подальше обернул в Thread.

fun writeImage(img: BufferedImage, file: String) {
    val imgthread = Thread(Runnable {
        ImageIO.write(img, File(file).extension, File(file))
    })
    try {
        imgthread.start()
    } catch (ex: Exception) {
        ex.printStackTrace()
        imgthread.interrupt()
    }
}

Останавливаться на этом было глупо, поэтому следующим шагом решил сделать "рисовалку" на базе двумерного списка.

Пиксельное сердце

Координаты для отрисовки решил сделать в виде двумерного списка ArrayList<List<Int>>. Получить "пиксельный" эффект мне помогла функция drawTitle, которая "дергает" в цикле drawPixel, рисуя "big pixel" в виде плитки.

fun drawTile(
    startX: Int, startY: Int, size: Int, 
    red: Int, green: Int, blue: Int, image: BufferedImage
) {
    for (posX in startX until startX+size) {
        for (posY in startY until startY+size) {
            drawPixel(posX,posY,red,green,blue,image)
        }
    }
}

Настала очередь обработать массив с числами. Сказано-сделано. Добавив с помощью оператора when обработку 4 цветов…

fun drawImage(pixels: ArrayList<List<Int>>, image: BufferedImage) {
    pixels.forEachIndexed { posY, row ->
        row.forEachIndexed { posX, col ->
            when(col) {
                1 -> drawTile(posX*10,posY*10,10,255,2,0,image)
                2 -> drawTile(posX*10,posY*10,10,156,25,31,image)
                3 -> drawTile(posX*10,posY*10,10,255,255,255,image)
                else -> drawTile(posX*10,posY*10,10,23,0,44,image)
            }
        }
    }
}

…и создав список в виде двумерного массива, где каждая цифра соответствует своему цвету (1 = красный, 2 = темно-красный, 3 = белый, 4 = фиолетовый)

val map = arrayListOf(
    listOf(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0),
    listOf(0,0,0,1,1,1,0,0,0,1,2,2,0,0,0),
    listOf(0,0,1,3,3,1,1,0,1,1,1,2,2,0,0),
    listOf(0,1,3,3,1,1,1,1,1,1,1,1,2,2,0),
    listOf(0,1,3,1,1,1,1,1,1,1,1,1,2,2,0),
    listOf(0,1,1,1,1,1,1,1,1,1,1,1,2,2,0),
    listOf(0,1,1,1,1,1,1,1,1,1,1,1,2,2,0),
    listOf(0,0,1,1,1,1,1,1,1,1,1,2,2,0,0),
    listOf(0,0,0,1,1,1,1,1,1,1,2,2,0,0,0),
    listOf(0,0,0,0,1,1,1,1,1,2,2,0,0,0,0),
    listOf(0,0,0,0,0,1,1,1,2,2,0,0,0,0,0),
    listOf(0,0,0,0,0,0,1,2,2,0,0,0,0,0,0),
    listOf(0,0,0,0,0,0,0,2,0,0,0,0,0,0,0),
    listOf(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0),
)

...на выходе получил такую красоту. Мой внутренний "школьник" был очень доволен.

pixel heart
pixel heart

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

Excel как холст

Следующим вечером я продолжил. Сперва подумал о JS (React JS), но тут нужно было переписывать все полностью на нем, да и JavaScript я пробовал слишком давно. Хотелось взять что-то простое…

По работе часто приходится работать с таблицами, поэтому само собой выбор остановился на Excel. Привел строки столбцы к виду квадратной сетки и вуаля - наш холст готов к работе с цифровыми красками. Осталось лишь только получить данные из ячеек. "Цифровая бумага все стерпит" - подумал я, и взял Apache POI - библиотеку для работы файлами word, excel, pdf. Документация у нее написана хорошо, но некоторые примеры кода там явно требуют корректировки.

Для начала набросал простую лямбду для преобразования hex в rgba, которая отдает стандартный джавовский класс Color.

val toRGBA = { hex: String ->
    val red = hex.toLong(16) and 0xff0000 shr 16
    val green = hex.toLong(16) and 0xff00 shr 8
    val blue = hex.toLong(16) and 0xff
    val alpha = hex.toLong(16) and 0xff000000 shr 24
    Color(red.toInt(),green.toInt(),blue.toInt(),alpha.toInt())
}

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

fun getPixelColors(file: String, listName: String): ArrayList<List<String>> {
    val table = FileInputStream(file)
    val sheet = WorkbookFactory.create(table).getSheet(listName)

    val rowIterator: Iterator<Row> = sheet.iterator()
    val rowArray: ArrayList<Int> = ArrayList()
    val cellArray: ArrayList<Int> = ArrayList()

    while (rowIterator.hasNext()) {
        val row: Row = rowIterator.next()
        rowArray.add(row.rowNum)
        val cellIterator = row.cellIterator()

        while (cellIterator.hasNext()) {
            val cell = cellIterator.next()
            cellArray.add(cell.address.column)
        }
    }
    val rowSize = rowArray.maxOf { el->el }
    //...проходим по листу 
    //...и формируем массив
    return pixelMatrix
}

Функция немаленькая и всю ее приводить я не буду (ссылка на код в конце статьи). Конечно, ее можно сократить, но ради читаемости я оставил все как есть. И тут хотелось бы остановиться на одном моменте.

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

val rows = sheet.lastRowNum
val cells = sheet.getRow(rows).lastCellNum // + rows

val pixArray = Array(rows+1) {Array(ccc+1) {""} }

...то Вы получите ошибку OutOfBounds. Количество строк (row) получается всегда правильным, но количество ячеек порой то меньше, то больше чем нужно. Я так и не понял, почему результат "скачет", причем проявляется это рандомно. Исправить это можно при помощи iterator.hasNext(), который реально возвращает последнюю ячейку.

Редактор пикселей в Excel
Редактор пикселей в Excel

Дело сталось за малым - преобразовать нашу "пиксельную матрицу" в картинку и вернуть в качестве результата BufferedImage. В отличии от начала статьи, тип картинки у нас изменился на - TYPE_INT_ARGB, чтобы не закрашенные ячейки таковыми и оставались.

fun renderImage(pixels: ArrayList<List<String>>): BufferedImage {
    val resultImage = BufferedImage(
        pixels[0].size*10,
        pixels.size*10,
        BufferedImage.TYPE_INT_ARGB
    )
    pixels.forEachIndexed { posY, row ->
        row.forEachIndexed { posX, col ->
            drawTile(
                (posX)*10,(posY)*10, 10,
                toRGBA(col).red, toRGBA(col).green,toRGBA(col).blue,
                toRGBA(col).alpha, resultImage
            )
        }
    }
    return resultImage
}

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

отрисованная картина в Excel. за основу взята работа Mockingjay1701
отрисованная картина в Excel. за основу взята работа Mockingjay1701

Выводы

Весь код доступен по ссылке на github. Что дальше? В планах добавить поддержку svg, может добавить несколько фильтров (blur, glitch, glow, etc..), переписать все с “индусского кода” на человеческий, добавить поддержку xls (HSSF Color) и возможно набросать пару тестов. Чего-то больше добавлять не имеет смысла, так как это скорее интересная задача с легким налетом ностальгии, чем какой-то проект.

Послесловие

Конечно, можно было ограничиться лишь "Фотошопом и Экселем" (ctrl+c, ctrl+v), но цель была не просто получить пиксельный "шедевр" в пару кликов. Хотелось вспомнить школьные уроки информатики, ту теплую атмосферу: Бейсик, старые компьютеры, пиксельные рисунки на экране черно-белого монитора "Электроника МС". Да черт побери, в конечном счете это хоть и простая, но интересная задача, потратить на которую пару вечеров просто приятно.

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

Пусть через пару лет "Электронику МС" сменили современные аналоги на базе Pentium, те первые занятия на старых компьютерах навсегда останутся со мной, ведь именно они вложили в меня любовь к компьютерам и всему что с ними связано...

А с чего начиналась информатика у Вас в школе?

Всем спасибо! Всем пока!