Матчасть
March 5

Разработка 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.

Пишем код, нажимаем Run, в Output смотрим, что получилось

Для понимания основ есть статья: (Ru) Учим язык Lua за 15 минут (оригинал на английском). Код из статьи можно копировать и вставлять, как есть. Блоки про метатаблицы, ООП и модули можно пока пропустить.

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

Факториал

Поработайте с такой важной структурой данных, как таблицы. Таблицы — основная структура данных в Lua. С помощью них вы будете формировать массивы, списки. Например, в таблицах вы будете хранить ссылки на игровые объекты в сцене или в кэше.

Изучите стандартную для Lua библиотеку table. Вам будут нужны такие функции, как table.insert(), table.remove().

Помните, что если вам нужно вставить элемент в конец таблицы, то есть два способа:

table.insert(t, element)

или (более предпочтительный, так как считается читабельнее и не требует вызова функции и, следовательно, чуть быстрее):

t[#t+1] = element

Научитесь добавлять и извлекать (и удалять) элементы из таблицы, делать циклы для обхода элементов таблицы.

Узнайте, в чем отличие следующих конструкций:

  • for i = 1, #t do
  • for … in ipairs(…) do
  • for … in pairs(…) do

Разберите следующий пример:

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

ООП в 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 перед полями и методами. Мы, таким образом, ссылаемся на полученный экземпляр, который пришел в метод в качестве аргумента.

Вот два идентичных куска кода для нашего класса:

1.

function Ghost:isVisible()
    return self.visible
end

ghost = Ghost()
print(ghost:isVisible())

2.

function Ghost.isVisible(self)
    return self.visible
end

ghost = Ghost()
print(ghost.isVisible(ghost))
-- Что, в свою очередь, идентично:
-- print(Ghost.isVisible(ghost))
-- А это уже функция класса в чистом виде

Спрайты

Чтобы отображать объекты на сцене будут использоваться спрайты. В спрайты можно будет загружать уже готовые изображения или подготавливать изображения прямо в процессе выполнения программы.

Спрайты оптимизируют процесс рендеринга на устройстве. Экран обновляется только в тех местах, где действительно что-то изменилось. Например, если один из спрайтов сдвинулся с места, то экран перерисуется только в области этого спрайта. Если спрайт своим движением «задел» другой спрайт, то последний также будет частично перерисовываться, так как он станет «грязным» и ему нужно будет обновить свое визуальное состояние.

Чтобы увидеть, как работает перерисовка спрайтов, включите в симуляторе опцию: 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

Я использую секвенции, чтобы делать анимированные элементы интерфейса (появление, исчезновение, движение в определенную точку). Продемонстрирую на примере выезжающего спрайта.

main.lua:

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.lua:

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

Noble Engine Example Project

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

Трюк основан на том, что:

  • Оператор and возвращает первое ложное значение среди своих операндов; если оба операнда истинны, возвращается последний из них
  • Оператор or возвращает первое истинное значение среди своих операндов; если оба операнда ложны, возвращается последний из них
  • Если в качестве аргумента в функцию передается таблица, то можно опустить скобки
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})

Туториалы по пиксель-арту

Здесь я оставлю список для изучения: классные туториалы по пиксель-арту и анимации. Возможно, пригодятся.

Другие полезные ссылки

Большой сборник всех ссылок, библиотек под Playdate:

Еще один список на Playdate Community Wiki:

Арт ресурсы:

Снипеты (куски кода, которые могут быть полезны в работе):

Чеклист хорошего пользовательского опыта на Playdate:

Соцсети, сообщества

Русскоязычный телеграмм-чат:

ВК:

Reddit:

Англоязычный Discord server:

Лента аккаунтов Playdate-разработчиков в Твиттере: