Разработка Playdate-игр на Lua. Заметки
Введение
Эта статья посвящена разработке игр под Playdate. Playdate — это небольшая портативная консоль с ручкой и однобитным черно-белым экраном без подсветки. Консоль сделана «for fun». В нее весело играть, но еще интереснее под нее разрабатывать.
Обзоров по Playdate уже достаточно много в русском сегменте, вы легко сможете их нагуглить на ютубе. Также для более близкого знакомства с консолью можно вступить в сообщество Playdate во Вконтакте.
В состав Playdate SDK, который бесплатен, входит эмулятор, поэтому начать разрабатывать игру можно, не имея на руках самого устройства.
С помощью эмулятора вы также можете ознакомиться с играми под консоль, которые доступны на Itch.io. Но в игры из официального каталога и сезона на эмуляторе сыграть не получится, нужно устройство.
Здесь я собрал мои накопившиеся заметки, которые были сделаны во время разработки первой игры для Playdate. Игра «Move & Match» уже выпущена и находится в каталоге.
Заметки могут быть полезны как тем, кто хочет начать разрабатывать под Playdate, так и тем, кто уже это делает.
Lua. Начало
Можно начинать путь сразу со знакомства с языка, на котором будем писать код: Lua. Для понимания азов не понадобится даже Playdate SDK. Можно начать в любом онлайн-редакторе Lua. Например, в https://onecompiler.com/lua.
Для понимания основ есть статья: (Ru) Учим язык Lua за 15 минут (оригинал на английском). Код из статьи можно копировать и вставлять, как есть. Блоки про метатаблицы, ООП и модули можно пока пропустить.
Поэкспериментируйте с выражениями, попробуйте сделать небольшие функции, такие как, например, вычисление факториала, возведение числа в степень.
Поработайте с такой важной структурой данных, как таблицы. Таблицы — основная структура данных в Lua. С помощью них вы будете формировать массивы, списки. Например, в таблицах вы будете хранить ссылки на игровые объекты в сцене или в кэше.
Изучите стандартную для Lua библиотеку table. Вам будут нужны такие функции, как table.insert(), table.remove().
Помните, что если вам нужно вставить элемент в конец таблицы, то есть два способа:
table.insert(t, element)
или (более предпочтительный, так как считается читабельнее и не требует вызова функции и, следовательно, чуть быстрее):
t[#t+1] = element
Научитесь добавлять и извлекать (и удалять) элементы из таблицы, делать циклы для обхода элементов таблицы.
Узнайте, в чем отличие следующих конструкций:
u = {} u[-1] = "y" u[0] = "z" u[1] = "a" u[3] = "b" u[2] = "c" u[4] = "d" u[6] = "e" u["hello"] = "world" for i = 1, #u do print(i, u[i]) end --[[ Output: 1 a 2 c 3 b 4 d 5 nil 6 e --]] print('---') for key, value in ipairs(u) do print(key, value) end --[[ Output: 1 a 2 c 3 b 4 d --]] print('---') for key, value in pairs(u) do print(key, value) end --[[ Output: 1 a 2 c 3 b 4 d 6 e 0 z hello world -1 y --]]
⚠️ Будьте аккуратны при удалении элементов из таблицы в цикле. Следующий код может привести к непредсказуемым результатам:
u = {'To redact', 'To delete', 'To redact', 'To redact'} for key, value in ipairs(u) do if value == 'To delete' then table.remove(u, key) else u[key] = 'Redacted' end end for key, value in ipairs(u) do print(key, value) end --[[ Output: 1 Redacted 2 To redact 3 Redacted --]]]
Разберите этот пример. Что произошло с таблицей внутри цикла? Почему не все элементы таблицы были отредактированы?
Изучите, что может библиотека math. Как получать случайные значения, производить такие операции, как нахождение максимума, минимума, модуля, извлечение корня. Как округлять числа в большую или меньшую сторону.
Будьте аккуратны при работе с дробными числами. Помните про возможные потери точности при операциях.
print(0.101 + 0.098 - 0.098 == 0.101) -- true print(0.102 + 0.098 - 0.098 == 0.102) -- but false
Настройка проекта (Windows)
Шаблон пустого проекта, который упоминается в видео.
Документация Playdate SDK
- (Ru) Перевод статьи «Designing for Playdate» (Оригинал на английском)
- Главная документация по Playdate SDK для Lua. Держите под рукой. Для старта прочтите следующие главы (остальные главы будете изучать по мере погружения):
ООП в Lua
Используйте следующие паттерны ООП:
Объявление класса и конструктора
-- Файл Ghost.lua Ghost = {} class('Ghost').extends() -- или class('Ghost').extends('Monster'), если нужно наследование function Ghost:init() -- конструктор Ghost.super.init(self) -- Конструкция для наследования родительского метода. -- Ее может не быть, если нечего наследовать или вы не хотите этого делать self.visible = false -- поле экземпляра end
Методы класса
-- "Статичный" метод или метод класса. Метод, который не обращается к полям экземпляра function Ghost.boo() print('Boo') end -- Нестатичный метод или метод экземпляра. Метод будет обращаться к полям конкретного экземпляра function Ghost:isVisible() return self.visible -- поле экземпляра end
Создание экземпляра класса
ghost = Ghost() -- Создали экземпляр класса.
Методы и поля экземпляра
ghost.visible = true print(ghost.visible) -- true print(ghost.boo()) -- Boo -- ghost.boo() идентично Ghost.boo() print(ghost:isVisible()) -- true -- ghost.isVisible() приведет к ошибке
Объяснение двоеточий
Факт, который поможет вам чуть лучше понять, как работает ООП в Lua и почему у «статичных» методов класса — точки перед методом, а у экземпляров — двоеточия:
function ClassName:fn(...)
является сокращенной формой записи
function ClassName.fn(self, ...)
Когда, вы вызываете метод у экземпляра класса через двоеточие, в действительности, вы вызываете метод у класса, передавая ему ссылку на экземпляр. Метод класса, в свою очередь, делает нужные манипуляции с экземпляром.
Поэтому и нужно указывать в теле метода класса ключевое слово self перед полями и методами. Мы, таким образом, ссылаемся на полученный экземпляр, который пришел в метод в качестве аргумента.
Вот два идентичных куска кода для нашего класса:
function Ghost:isVisible() return self.visible end ghost = Ghost() print(ghost:isVisible())
function Ghost.isVisible(self) return self.visible end ghost = Ghost() print(ghost.isVisible(ghost)) -- Что, в свою очередь, идентично: -- print(Ghost.isVisible(ghost)) -- А это уже функция класса в чистом виде
Спрайты
Чтобы отображать объекты на сцене будут использоваться спрайты. В спрайты можно будет загружать уже готовые изображения или подготавливать изображения прямо в процессе выполнения программы.
- Документация SDK: 6.20. Graphics/Sprite
Спрайты оптимизируют процесс рендеринга на устройстве. Экран обновляется только в тех местах, где действительно что-то изменилось. Например, если один из спрайтов сдвинулся с места, то экран перерисуется только в области этого спрайта. Если спрайт своим движением «задел» другой спрайт, то последний также будет частично перерисовываться, так как он станет «грязным» и ему нужно будет обновить свое визуальное состояние.
Чтобы увидеть, как работает перерисовка спрайтов, включите в симуляторе опцию: View/Highlight Screen Updates. Желтым цветом будут отмечены области перерисовки.
В некоторых случаях, вам нужно будет сделать так, чтобы экран перерисовывался каждый кадр всегда целиком. Для этого используйте метод:
playdate.graphics.sprite.setAlwaysRedraw(true)
Это необходимо делать в тех случаях, когда ваша игра настолько динамична, что нет никакой необходимости вычислять, какие области должны перерисовываться, а какие нет. А вычисление областей перерисовки занимает время, что бьет по производительности.
Однако, если ваша игра довольно статична (например, пошаговая головоломка), то стоит оставить флаг выключенным. Под статичностью я понимаю, то что в такой игре, в основном, изменения происходят только в определенных областях экрана.
Напоминаю, что если вы рисуете графику «на лету», через draw-методы, то кешируйте ее в изображения. Не рисуйте одни и те же сложные объекты по несколько раз.
То же касается и текста. Если вы хотите отображать в качестве спрайта текст, то превратите текст в изображение. Отрисовка текста требует выделение памяти и занимает время. Будет хороший удар по производительности, если в вашей игре в каждом кадре где-то будет рисоваться один и тот же текст.
Коллизии
Спрайты позволяют вычислять коллизии: кто с кем столкнулся, и кто кого пересекает.
Функционал ограничен: хитбоксы могут быть только прямоугольные и выровненные по осям координат (нельзя поворачивать). Но этого должно хватить, чтобы сделать игру, например, в жанре Shoot-em-up или платформера, без использования физического движка.
Помните, что в хороших играх хитбоксы героя, врагов и препятствий всегда чуть меньше, чем области их спрайтов. А у бонусов и у вашего оружия, наоборот, хитбоксы побольше.
Чтобы увидеть коллайдеры в симуляторе используйте опцию View/Show Sprite Collisions:
Можно использовать метод alphaCollision и таким образом проверять столкновения на уровне пикселей. Сначала определяем столкновение прямоугольными коллайдерами, а потом уточняем столкновение попиксельно.
Анимированные спрайты
Изучите раздел документации про структуру imagetable: 6.20 Graphics/Image table. С помощью этой структуры вы будете создавать последовательности кадров для ваших анимаций.
Для создания анимированных спрайтов используйте библиотеку AnimatedSprite от Whitebrim. Библиотека хорошо задокументирована, посмотрите примеры использования.
Не забывайте кешировать загруженные imagetables и стейты из json, если вам нужно создавать несколько спрайтов с одной и той же анимацией.
YourAnimatedSprite = {} class('YourAnimatedSprite').extends(AnimatedSprite) local imagetable = playdate.graphics.imagetable.new('assets/images/animation') local states = AnimatedSprite.loadStates('assets/images/animation.json') function YourAnimatedSprite:init() YourAnimatedSprite.super.init(self, imagetable, states, true) end
Анимация движения
Для плавного перемещения объектов или плавного изменения их свойств во времени можно использовать класс PlaydateSequence от NicMagnier.
Класс позволяет настроить и запустить секвенции. Секвенция — простыми словами, это поведение некоторого параметра во времени. Например, вы можете создать секвенцию, которая изменяет условный параметр от 0 до 10 за 5 секунд, а потом от 10 до 15 еще за 1 секунду. Затем вы можете считывать этот параметр секвенции в каждом кадре игры и применять его уже непосредственно к свойству некоторого объекта, например, менять координату спрайта. Спрайт, соответственно, будет двигаться.
Изучите примеры в описании.
Если до этого никогда не сталкивались с функциями плавности, вот шпаргалка про то, как они работают: https://easings.net/ru
Я использую секвенции, чтобы делать анимированные элементы интерфейса (появление, исчезновение, движение в определенную точку). Продемонстрирую на примере выезжающего спрайта.
import "CoreLibs/object" import 'CoreLibs/graphics' import 'CoreLibs/sprites' import 'sequence' -- Положите sequence.lua рядом с main.lua Graphics = playdate.graphics import 'PopupSprite' function showEndCallback() popupSprite:hide(hideEndCallback) end function hideEndCallback() popupSprite:show(200, 120, showEndCallback) end popupSprite = PopupSprite() popupSprite:show(200, 120, showEndCallback) function playdate.update() sequence.update() Graphics.sprite.update() end
PopupSprite = {} class('PopupSprite').extends(Graphics.sprite) function PopupSprite:init() local image = Graphics.image.new(50, 50) Graphics.pushContext(image) Graphics.setColor(Graphics.kColorBlack) Graphics.fillCircleInRect(0, 0, 50, 50) Graphics.popContext() PopupSprite.super.init(self, image) self._sequenceY = nil end function PopupSprite:remove() PopupSprite.super.remove(self) self:_stopSequences() end function PopupSprite:_stopSequences() if self._sequenceY ~= nil then self._sequenceY:stop() self._sequenceY = nil end end function PopupSprite:update() PopupSprite.super.update(self) self:_updatePosition() end function PopupSprite:_updatePosition() if self._sequenceY ~= nil then self:moveTo(self.x, self._sequenceY:get()) end end function PopupSprite:show(x, y, callback) local function sequenceCallback() self:_updatePosition() self._sequenceY = nil if callback ~= nil then callback() end end self:_stopSequences() self:addSprite() self:moveTo(x, y - 240) self._sequenceY = sequence.new():from(y - 240):to(y, 1, 'outBack'):callback(sequenceCallback) self._sequenceY:start() end function PopupSprite:hide(callback) local function sequenceCallback() self:_updatePosition() self._sequenceY = nil self:removeSprite() if callback ~= nil then callback() end end self:_stopSequences() self._sequenceY = sequence.new():from(self.y):to(self.y - 240, 1, 'inBack'):callback(sequenceCallback) self._sequenceY:start() end
Можно использовать секвенции для различных «программных» анимаций. Например, можно сделать бегущие цифры на счетчике или сделать мигание (для этого внутри апдейтов интерпретируйте текущие значения в секвенции так, как вам нужно).
VFX
Библиотека для генерации частиц от автора игры Rocket Bytes:
https://github.com/PossiblyAxolotl/pdParticles
Для генерации тяжеловесных, предварительно отрендеренных анимаций из частиц есть такой инструмент: https://sphodromantis.itch.io/particlesizzler
Еще есть такой вот инструмент для предварительной генерации спрайтов проливного дождя: https://pixeladdictgames.itch.io/rain-maker
Scene management
Вам понадобится создавать различные сцены в игре, как минимум, главное меню и сцену самой игры. Для этого хорошо бы иметь менеджер сцен.
Есть видео, которое рассказывает, что такое сцены и как можно сделать свой менеджер сцен:
Я рекомендую использовать готовые решения, фреймворки, которые уже предоставляют возможности для управления сценами:
1. Noble engine: https://noblerobot.com/nobleengine
2. Другой фреймворк для управления сценами: Roomy for Playdate. https://github.com/RobertCurry0216/roomy-playdate
User interface
Gridview
Для создания списка элементов, например, пунктов меню, используйте класс playdate.ui.gridview:
Традиционный совет по оптимизации: кешируйте картинки для отрисовки элементов. Особенно, если в элементах есть тексты.
Создайте массив закешированных элементов сетки и отрисовывайте их в методах gridview:drawCell() уже в готовом виде.
Pd-options
Pd-options — многофункциональная библиотека для создания меню, списков настроек и так далее: https://github.com/macvogelsang/pd-options
Изначально использовалась в Sparrow Solitaire (у игры есть Demo). Одно из самых функциональных приложений на Playdate, много опций, настроек, рекомендую изучить демо-версию.
Playout
Playout — библиотека для создания попапов.
https://github.com/potch/playout
DisplayObject
Мой небольшой класс DisplayObject, который будет полезен в верстке интерфейсов и, возможно, не только. Позволяет сделать вложенные друг в друга отображаемые на экране объекты. С помощью него можно сделать интерфейсы с иерархией, когда есть родительские и дочерние объекты. Например, выезжающая страница с кнопками и текстами, внутри которой еще есть анимированный блок с кнопками.
Если хотите использовать методы апдейта, то достаточно обновлять родительский DisplayObject. Обновления будут передаваться дочерним объектам.
Метод Display нужен для добавления родительского объекта на сцену.
Поддерживается вложенность позиций и свойств видимости.
Звуки
Для работы со звуками прочитайте главу 6.27. Sound.
Для приложений под Playdate конвертируйте музыку и звуки в формат WAV c кодеком IMA ADCPM с помощью бесплатного редактора Audacity. Так файлы будут весить немного, при этом нагрузка на процессор будет минимальной. (Так указано в документации.)
Есть смысл сохранять звуки и музыку в моно. Так файлы будут занимать в два раза меньше места.
На устройстве моно-динамик, но есть вход для наушников. Также для Playdate была анонсирована стерео-докстанция. Выбор, оставлять ли звуки в стерео или нет, остается за вами.
Разное
Трюки в Lua
- Если хотите задать установить дефолтное значение для аргумента функции, то можно использовать такой код:
function func(x) x = x or 5 return x end print(func(1)) -- 1 print(func()) -- 5
function func(flag) if flag == nil then flag = true end return flag end print(func(false)) -- false print(func()) -- true
- В lua нет тернарного оператора. Но можно использовать для таких целей and-or конструкцию. (Правда ее можно использовать только, если в качестве возвращаемых аргументов не булевые типы и не nil. Поэтому используйте ее очень аккуратно. Или, в принципе, не используйте ее, чтобы избежать лишних ошибок. Выбор за вами.)
result = condition and x or y -- это аналог condition?x:y -- В качестве x не может быть false или nil
function KroneckerDelta(x, y) answer = x == y and 1 or 0 return answer end - print(func(5, 5)) -- 1 print(func(3, 2)) -- 0
function func(t) print t.x end func{x = 0}
Еще раз про оптимизацию
- Кешируйте тексты, которые будут двигаться, в картинки. Не рисуйте тексты в draw-методах.
- Пользуйтесь профайлером (Device → Show Device Info), чтобы понимать, насколько ваша программа производительна и как много ей требуется памяти. Если видите, что график использования памяти только растет, значит вы не освобождаете переменные для Garbage-коллектора.
- Пользуйтесь семплером (View → Show Sampler), чтобы отследить какие участки программы отжирают больше всего ресурсов процессора и требуют оптимизации.
- Слишком частое создание и удаление объектов может нагрузить Garbage-collector, из-за чего в игре могут быть небольшие фризы в неожиданных местах. Переиспользуйте объекты. Для этого храните объекты в пуле. Особенно это касается спрайтов. Если нужно, можете после доставания из пула переинициализировать их новыми настройками, но не создавайте экземпляры каждый раз с нуля.
ObjectViewPool = {} class('ObjectViewPool').extends() function ObjectViewPool:init() ObjectViewPool.super.init(self) self._objectViews = {} end function ObjectViewPool:push(objectView) self._objectViews[#self._objectViews + 1] = objectView end function ObjectViewPool:pop() if #self._objectViews == 0 then return nil else return table.remove(self._objectViews) end end
Баги
- Ошибки, которые могут встречаться в разработке:
- Неправильное использование точек и двоеточий в вызове функций экземпляра (по началу это будет ваша самая распространенная ошибка).
- Запутанные условия.
- Операции сравнения с дробными числами.
- Отсутствие проверки на nil у приходящего аргумента функции.
- Ошибки при работе с таблицами.
- Чтобы ловить баги, поможет функция printTable(). В отличие от print(), она выводит полное содержание таблицы в консоль.
Подготовка арта
Фотошоп или другой растровый редактор
Для подготовки картинок я использовал Adobe Photoshop. В основном, я рисовал векторным инструментом примитивные фигуры. Где-то приходилось что-то подрисовывать пиксельным карандашом, когда фигуры пикселизировались не так, как мне было нужно.
Сделал специальный шаблон для фотошопа. В нем включены специальные фильтры, чтобы делать картинку однобитной, без сглаживания: https://drive.google.com/file/d/1Y6t44-fo6h_AsAVRH47hWy88KiYXdKaS/view?usp=sharing
Мой подход предполагает следующий алгоритм действий:
- Элементы игры рисуются векторными инструментами.
- С помощью фильтров контролируется финальный вид картинки на однобитном экране Playdate.
- Когда картинка готова, чтобы ее нарезать, картинка растеризуется (c выключенным серым фильтром), сохраняется в отдельный Макет.PNG.
- Далее идет этап нарезки. На этом этапе из макета с помощью инструментов выделений вырезаются нужные куски картинки, создаются прозрачные области, подготавливаются ресурсы для игры. Делается это с помощью прямоугольного выделения, волшебной палочки, стерки.
Подход не идеален. При внесении любых изменений приходится повторять процедуру рендеринга макета и вырезания из него нужных частей картинки заново. Однако, более удобного способа рисовать и нарезать однобитные картинки я пока не нашел.
Aseprite
Возможно, стоит посмотреть в сторону редактора Aseprite. Особенно для создания анимаций для спрайтов. Но под мои нужды он пока не подошел, так как в нем нет векторных инструментов. Возможно, для следующей игры я попробую в нем поработать.
Демо-версия доступна на сайте: https://www.aseprite.org/
Сам редактор дешевле всего купить в Steam: https://store.steampowered.com/app/431730/Aseprite/
GFXP
Еще один важный инструмент: Редактор паттернов для Playdate от Ивана Сергеева.
https://dev.crankit.app/tools/gfxp/
С помощью него вы сможете легко подбирать паттерны для заливки, например, фонов.
В коде можно использовать библиотеку GFXP или использовать паттерны напрямую, как есть.
import 'lib/gfxp' local gfxp <const> = GFXP gfxp.set('darkgray-2') -- или просто playdate.graphics.setPattern({0x88, 0x88, 0x88, 0x88, 0x22, 0x22, 0x22, 0x22})
Туториалы по пиксель-арту
Здесь я оставлю список для изучения: классные туториалы по пиксель-арту и анимации. Возможно, пригодятся.
- https://app2top.ru/game_development/tutorialy-po-piksel-artu-ot-sozdatelya-towerfall-102632.html
- https://app2top.ru/game_development/tutorialy-po-piksel-artu-ot-avtora-fara-the-eye-of-darkness-93906.html
Другие полезные ссылки
Большой сборник всех ссылок, библиотек под Playdate:
Еще один список на Playdate Community Wiki:
Снипеты (куски кода, которые могут быть полезны в работе):
Чеклист хорошего пользовательского опыта на Playdate: