Всем привет! Идея для этой статьи пришла еще месяц назад, но в силу занятости на работе времени катастрофически не хватало. Однажды вечером в YouTube я наткнулся на ролик о создании игры-платформера в стиле пиксельной графики. И тут мне вспомнились мои первые уроки информатики в школе, где мы "рисовали на Бейсике" и играли в "ворона ест буквы".
Предисловие
На дворе стоял 2000-й год. Кризис 98 года остался позади. Я учился в 8 классе местной школы, в небольшом городке. С началом учебного года всех ждало небольшое событие - ввели урок информатики. Многие отнеслись к этому, как к еще одному предмету который надо учить, но были и те, у кого загорелись глаза. В числе последних оказался и я.
Надо отметить, что информатику хоть и ввели, но "ввести новые компьютеры" забыли, потому что денег на эти цели не было. На службе у нашей школы тогда стояли машины made in USSR - "Электроника МС 0511" и несколько их чуть более современных аналогов. Работали они только по им самим ведомым законам, или после прихода некоего "Николая Владимировича" - местного мастера.
Вести предмет как водится поставили молодого и "горячего" преподавателя - девушку 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 отличный от единицы и занизить один из каналов, то можно получить интересный эффект.
Вроде что-то вырисовывается. Теперь надо сохранить результат. Роль по записи изображения была героически возложена на 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),
)
...на выходе получил такую красоту. Мой внутренний "школьник" был очень доволен.
И хотя все получилось как я ожидал, но "рисовать цифрами" то еще удовольствие, да и хотелось на выходе получать что-то посложнее в плане цвета и детализации, поэтому я задумался о визуальном редакторе. Но запасы чая таяли на глазах, а вечер постепенно перетекал в ночь, поэтому решено было отложить задачу до завтра.
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(), который реально возвращает последнюю ячейку.
Дело сталось за малым - преобразовать нашу "пиксельную матрицу" в картинку и вернуть в качестве результата 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
}
Теперь, запасшись малиновым чаем и любимой музыкой можно придаться ностальгии и творить.
Выводы
Весь код доступен по ссылке на github. Что дальше? В планах добавить поддержку svg, может добавить несколько фильтров (blur, glitch, glow, etc..), переписать все с “индусского кода” на человеческий, добавить поддержку xls (HSSF Color) и возможно набросать пару тестов. Чего-то больше добавлять не имеет смысла, так как это скорее интересная задача с легким налетом ностальгии, чем какой-то проект.
Послесловие
Конечно, можно было ограничиться лишь "Фотошопом и Экселем" (ctrl+c, ctrl+v), но цель была не просто получить пиксельный "шедевр" в пару кликов. Хотелось вспомнить школьные уроки информатики, ту теплую атмосферу: Бейсик, старые компьютеры, пиксельные рисунки на экране черно-белого монитора "Электроника МС". Да черт побери, в конечном счете это хоть и простая, но интересная задача, потратить на которую пару вечеров просто приятно.
И раз уж текст скорее всего выйдет накануне 14 февраля, то пусть он будет своеобразным признанием в любви к технологиям, которыми я с того самого дня и по настоящее время увлечен.
Пусть через пару лет "Электронику МС" сменили современные аналоги на базе Pentium, те первые занятия на старых компьютерах навсегда останутся со мной, ведь именно они вложили в меня любовь к компьютерам и всему что с ними связано...
А с чего начиналась информатика у Вас в школе?
Всем спасибо! Всем пока!