Оглавление
- Статья 1
- Часть 1. Игровой цикл
- Часть 2. Библиотеки
- Часть 3. Комнаты и области
- Часть 4. Упражнения
- Статья 2
- Часть 5. Основы игры
- Часть 6. Основы класса Player
- Статья 3
- Часть 7. Параметры и атаки игрока
- Часть 8. Враги
- Статья 4
- Часть 9. Режиссёр и игровой цикл
- Часть 10. Практики написания кода
- Часть 11. Пассивные навыки
- Статья 5
- Часть 12. Другие пассивные навыки
- Статья 6
- Часть 13. Дерево навыков
- Статья 7
- Часть 14. Консоль
- Часть 15. Финал
Часть 14: Консоль
Введение
В этой части мы разберём комнату Console. Консоль реализовать гораздо проще, чем всё остальное, потому что в итоге она сводится к выводу на экран текста. Вот, как это выглядит:
Комната Console будет состоять из трёх разных типов объектов: строк, строк ввода и модулей. Строки — это просто обычные цветные строки текста, отображаемые на экране. Например, в показанном выше примере ":: running BYTEPATH..." будет являться строкой. С точки зрения структуры данных это будет просто таблица, хранящая позицию строки, её текст и цвета.
Строки ввода — это строки, в которые игрок может что-то вводить. В показанном выше примере это те строки, в которых есть слово «arch». При вводе определённых команд в консоль эти команды будут выполняться и создавать новые строки или модули. С точки зрения структуры данных строки ввода будут походить на простые строки, только с дополнительной логикой для считывания ввода, когда последняя строка, добавленная в комнату, является строкой ввода.
Наконец, модуль — это специальный объект, позволяющий игроку выполнять более сложные действия, чем простой ввод команд. Это целый набор элементов, которые появляются, когда игроку, например, нужно выбрать корабль. Такие объекты могут создаваться различными командами, то есть, например, когда игрок хочет изменить громкость звука в игре, он должен ввести «volume», после чего откроется модуль Volume, в котором можно будет выбрать уровень громкости. Все эти модули сами по себе тоже будут являться объектами, а комната Console соответствующим образом будет обрабатывать их создание и удаление.
Строки
Давайте начнём со строк. Мы можем определить строку таким образом:
{
x = x, y = y,
text = love.graphics.newText(font, {boost_color, 'blue text', default_color, 'white text'}
}
То есть у неё есть позиция x, y
, а также атрибут text
. Этот атрибут текста является объектом Text. Мы будем использовать объекты Text из LÖVE, потому что с их помощью легко можно определять цветной текст. Но прежде, чем мы сможем добавлять в комнату Console строки, нам нужно создать её, так что давайте займёмся этим. В основе своей эта задача похожа на создание комнаты SkillTree.
Мы добавим таблицу lines
, в которой будут храниться все текстовые строки, а затем в функции draw мы обойдём всю эту таблицу и отрисуем каждую строку. Также мы добавим функцию addLine
, которая будет добавлять новую текстовую строку в таблицу lines
:
function Console:new()
...
self.lines = {}
self.line_y = 8
camera:lookAt(gw/2, gh/2)
self:addLine(1, {'test', boost_color, ' test'})
end
function Console:draw()
...
for _, line in ipairs(self.lines) do love.graphics.draw(line.text, line.x, line.y) end
...
end
function Console:addLine(delay, text)
self.timer:after(delay, function()
table.insert(self.lines, {x = 8, y = self.line_y,
text = love.graphics.newText(self.font, text)})
self.line_y = self.line_y + 12
end)
end
Здесь происходит и кое-что ещё. Во-первых, тут есть атрибут line_y
, отслеживающий позицию по y, в которой мы должны добавить следующую строку. Он увеличивается на 12 каждый раз при вызове addLine
, потому что мы хотим, чтобы новые строки добавлялись под предыдущей, как это происходит в обычных терминалах.
Кроме того, у функции addLine
есть задержка. Эта задержка полезна, потому что при добавлении в консоль нескольких строк мы не хотим, чтобы они добавлялись одновременно. Мы хотим, чтобы перед каждым добавлением была небольшая задержка, потому что так всё выглядит лучше. Кроме того, мы можем здесь сделать так, чтобы вместе с задержкой добавления каждой строки она добавлялась посимвольно. То есть вместо одной строки, добавляемой за раз, каждый символ добавляется с небольшой задержкой, что даст нам ещё более приятный эффект. Ради экономии времени я не буду делать это сам, но это может стать хорошим упражнением для вас (и часть логики для этого у нас уже есть в объекте InfoText).
Всё это должно выглядеть так:
И если мы добавим несколько строк, то это будет выглядеть так, как и должно:
Строки ввода
Строки ввода немного сложнее, но совсем чуть-чуть. Первое, что мы хотим — добавить функцию addInputLine
, которая будет вести себя как addLine
, за исключением того, что будет добавлять текст строки ввода и включать возможность ввода текста игроком. По умолчанию мы будем использовать текст строки ввода [root]arch~
, размещаемый перед вводом, как и в обычном терминале.
function Console:addInputLine(delay)
self.timer:after(delay, function()
table.insert(self.lines, {x = 8, y = self.line_y,
text = love.graphics.newText(self.font, self.base_input_text)})
self.line_y = self.line_y + 12
self.inputting = true
end)
end
А base_input_text
выглядит следующим образом:
function Console:new()
...
self.base_input_text = {'[', skill_point_color, 'root', default_color, ']arch~ '}
...
end
Также при добавлении новой строки ввода мы присваиваем inputting
значение true. Это булево значение будет использоваться, чтобы сообщать нам, должны ли мы считывать ввод с клавиатуры. Если да, то мы можем просто добавлять в список как строку все символы, которые вводит игрок, а потом добавлять эту строку в наш объект Text. Это выглядит так:
function Console:textinput(t)
if self.inputting then
table.insert(self.input_text, t)
self:updateText()
end
end
function Console:updateText()
local base_input_text = table.copy(self.base_input_text)
local input_text = ''
for _, character in ipairs(self.input_text) do input_text = input_text .. character end
table.insert(base_input_text, input_text)
self.lines[#self.lines].text:set(base_input_text)
end
А Console:textinput
будет вызываться при каждом вызое love.textinput
, что происходит при каждом нажатии клавиши игроком:
-- in main.lua
function love.textinput(t)
if current_room.textinput then current_room:textinput(t) end
end
Последнее, что нам нужно сделать — обеспечить работу клавиш Enter и Backspace. Клавиша Enter будет присваивать inputting
значение false, получать содержимое таблицы input_text
и что-то с ней делать. То есть если игрок ввёл «help», а затем нажал на Enter, мы запустим команду help. А клавиша Backspace должна просто удалять последний элемент из таблицы input_text
:
function Console:update(dt)
...
if self.inputting then
if input:pressed('return') then
self.inputting = false
-- Run command based on the contents of input_text here
self.input_text = {}
end
if input:pressRepeat('backspace', 0.02, 0.2) then
table.remove(self.input_text, #self.input_text)
self:updateText()
end
end
end
Наконец, мы можем также симулировать приятный эффект мигающего курсора. Простейший способ сделать это — просто отрисовывать мигающий курсор в позиции после ширины base_input_text
, конкатенированной с содержимым input_text
.
function Console:new()
...
self.cursor_visible = true
self.timer:every('cursor', 0.5, function()
self.cursor_visible = not self.cursor_visible
end)
end
Таким образом мы реализуем мигание, отрисовывая прямоугольник только тогда, когда
cursor_visible
равно true. Далее мы отрисовываем прямоугольник:
function Console:draw()
...
if self.inputting and self.cursor_visible then
local r, g, b = unpack(default_color)
love.graphics.setColor(r, g, b, 96)
local input_text = ''
for _, character in ipairs(self.input_text) do input_text = input_text .. character end
local x = 8 + self.font:getWidth('[root]arch~ ' .. input_text)
love.graphics.rectangle('fill', x, self.lines[#self.lines].y,
self.font:getWidth('w'), self.font:getHeight())
love.graphics.setColor(r, g, b, 255)
end
...
end
Здесь в переменной x
хранится позиция курсора. Мы прибавляем к ней 8, потому что каждая строка по умолчанию отрисовывается, начиная с позиции 8, поэтому если мы не будем это учитывать, позиция курсора будет неверной. Также мы примем, что ширина прямоугольника курсора будет равна ширине буквы 'w' текущего шрифта. Обычно w является самой широкой буквой, поэтому мы выбрали её. Но это может быть и любой постоянное число, например 10 или 8.
И всё это будет выглядеть так:
Модули
Модули — это объекты. в которых содержится логика, позволяющая игроку делать что-то в консоли. Например, ResolutionModule
, который мы реализуем, позволит игроку менять разрешение в игре. Мы отделим модули от остальной части кода комнаты Console, потому что их логика может быть довольно сложной, и логично выделить их в отдельные объекты. Мы реализуем модуль, который выглядит так:
Этот модуль создаётся и добавляется, когда игрок нажимает Enter после ввода в строку ввода команды «resolution». После активации модуля он перехватывает управление у консоли и добавляет в неё несколько строк с помощью Console:addLine
. Кроме этих добавленных линий у него есть логика выбора, позволяющая подобрать нужное разрешение. После выбора разрешения и нажатия на Enter окно изменяется, чтобы отразить это новое разрешение, мы добавляем новую строку ввода с помощью Console:addInputLine
и отключаем возможность выбора в этом объекте ResolutionModule, возвращая управление консоли.
Все модули будут работать приблизительно таким же образом. Они создаются/добавляются, выполняют свои функции, перехватывая управление у комнаты Console, а затем, после того, как их поведение завершится, возвращают управление консоли. Мы можем реализовать основы модулей в объекте Console следующим образом:
function Console:new()
...
self.modules = {}
...
end
function Console:update(dt)
self.timer:update(dt)
for _, module in ipairs(self.modules) do module:update(dt) end
if self.inputting then
...
end
function Console:draw()
...
for _, module in ipairs(self.modules) do module:draw() end
camera:detach()
...
end
Поскольку мы пишем код только для себя, здесь мы можем пропустить некоторые формальности. Хотя только что сказал, что у нас будет некоторое правило/интерфейс между объектом Console
и объектами Module, через которое они будут передавать управление ввода игрока друг другу, на самом деле мы просто будем добавлять модули в таблицу self.modules
, обновлять и отрисовывать их. В соответствующее время каждый модуль будет сам активироваться/дезактивироваться, то есть со стороны Console нам не понадобиться почти ничего делать.
Теперь давайте рассмотрим создание ResolutionModule:
function Console:update(dt)
...
if self.inputting then
if input:pressed('return') then
self.line_y = self.line_y + 12
local input_text = ''
for _, character in ipairs(self.input_text) do
input_text = input_text .. character
end
self.input_text = {}
if input_text == 'resolution' then
table.insert(self.modules, ResolutionModule(self, self.line_y))
end
end
...
end
end
Здесь мы делаем так, что в переменной input_text
будет храниться то, что игрок ввёл в строку ввода, а затем, если этот текст равен «resolution», мы создаём новый объект ResolutionModule и добавляем его в список modules
. Большинству модулей потребуется ссылка на консоль, а также текущая позиция y, в которую добавляются строки, поэтому модуль будет расположен под строками кода, уже имеющимися в консоли. Для этого при создании нового объекта модуля мы передаём self
и self.line_y
.
Реализация ResolutionModule сама по себе достаточно проста. Для неё нам достаточно добавить несколько строк, а также небольшое количество логики выбора из нескольких строк. Для добавления строк мы просто делаем следующее:
function ResolutionModule:new(console, y)
self.console = console
self.y = y
self.console:addLine(0.02, 'Available resolutions: ')
self.console:addLine(0.04, ' 480x270')
self.console:addLine(0.06, ' 960x540')
self.console:addLine(0.08, ' 1440x810')
self.console:addLine(0.10, ' 1920x1080')
end
Чтобы упростить свою работы, мы сделаем так, что все доступные разрешения будут значениями, кратными базовому разрешению, поэтому нам достаточно добавить эти четыре строки.
После этого нам осталось добавить логику выбора. Логика выбора похожа на хак, но хорошо работает: мы просто помещаем прямоугольник поверх текущей выбранной строки и перемещаем этот прямоугольник при нажатии игроком клавиш «вверх» и «вниз». Нам потребуется переменная для отслеживания строки, в которой мы находимся (с 1 по 4), и мы будем отрисовывать этот прямоугольник в соответствующей позиции y на основании этой переменной. Всё это выглядит следующим образом:
function ResolutionModule:new(console, y)
...
self.selection_index = sx
self.selection_widths = {
self.console.font:getWidth('480x270'), self.console.font:getWidth('960x540'),
self.console.font:getWidth('1440x810'), self.console.font:getWidth('1920x1080')
}
end
Переменная selection_index
отслеживает текущий выбор, и изначально он равен sx
. sx
может быть равно 1, 2, 3 или 4, в зависимости от размера, выбранного main.lua
при вызове функции resize
. selection_widths
хранит ширины прямоугольника в каждой строке выбора. Поскольку прямоугольник должен закрывать каждое разрешение, нам нужно определить его размер на основании размера символов, составляющих строку этого разрешения.
function ResolutionModule:update(dt)
...
if input:pressed('up') then
self.selection_index = self.selection_index - 1
if self.selection_index < 1 then self.selection_index = #self.selection_widths end
end
if input:pressed('down') then
self.selection_index = self.selection_index + 1
if self.selection_index > #self.selection_widths then self.selection_index = 1 end
end
...
end
В функции update мы обрабатываем логику нажатия игроком «вверх» и «вниз». Нам нужно просто увеличивать или уменьшать selection_index
так, чтобы значение было не меньше 1 и не больше 4.
function ResolutionModule:draw()
...
local width = self.selection_widths[self.selection_index]
local r, g, b = unpack(default_color)
love.graphics.setColor(r, g, b, 96)
local x_offset = self.console.font:getWidth(' ')
love.graphics.rectangle('fill', 8 + x_offset - 2, self.y + self.selection_index*12,
width + 4, self.console.font:getHeight())
love.graphics.setColor(r, g, b, 255)
end
А в функции draw мы просто отрисовываем прямоугольник в соответствующей позиции. Код снова выглядит ужасно и в нём полно странных чисел, но нам нужно расположить прямоугольник в нужном месте, и для этого не существует «чистых» способов.
Теперь нам осталось только сделать так, чтобы объект считывал ввод только когда он активен, и чтобы он был активен только сразу после его создания и до нажатия игроком Enter для выбора разрешения. После нажатия на Enter он должен становиться неактивным и больше не считывать ввод. Проще всего сделать это следующим образом:
function ResolutionModule:new(console, y)
...
self.console.timer:after(0.02 + self.selection_index*0.02, function()
self.active = true
end)
end
function ResolutionModule:update(dt)
if not self.active then return end
...
if input:pressed('return') then
self.active = false
resize(self.selection_index)
self.console:addLine(0.02, '')
self.console:addInputLine(0.04)
end
end
function ResolutionModule:draw()
if not self.active then return end
...
end
Переменной active
присваивается значение true через несколько кадров после создания модуля. Благодаря этому прямоугольник не будет отрисовываться до добавления строк, потому что строки добавляются с небольшой задержкой. Если переменная active
не активна, то функции update и draw не будут выполняться, то есть мы не будем считывать ввод для этого объекта и отрисовывать прямоугольник выбора. Кроме того, при нажатии на Enter мы присваиваем active
значение false, вызываем функцию resize
, а затем передаём управление обратно Console, добавляя новую строку ввода. Всё это даёт нам соответствующее поведение и благодаря этому всё будет работать нужным образом.
Упражнения
227. (КОНТЕНТ) Сделайте так, чтобы когда в комнате Console больше строк, чем может поместиться на экране, камера опускалась вниз при добавлении строк и модулей.
228. (КОНТЕНТ) Реализуйте модуль AchievementsModule
. Он показывает все достижения и требования, необходимые для их разблокировки. Достижения мы рассмотрим в следующей части туториала, поэтому вернитесь к этому упражнению позже!
229. (КОНТЕНТ) Реализуйте модуль ClearModule
. Этот модуль позволяет удалять все сохранённые данные или очищать дерево навыков. Сохранение/загрузка данных тоже будут рассмотрены в следующей статье, поэтому к этому упражнению также можно вернуться позже.
230. (КОНТЕНТ) Реализуйте модуль ChooseShipModule
. Этот модуль позволяет игроку выбирать и разблокировать корабли для игрового процесса.
231. (КОНТЕНТ) Реализуйте модуль HelpModule
. Он отображает все доступные команды и позволяет игроку выбирать команду, не вводя текст. В игре будет поддерживаться геймпад, поэтому заставлять игроков вводить что-то не очень хорошо.
232. (КОНТЕНТ) Реализуйте модуль VolumeModule
. Он позволяет игроку выбирать громкость звуковых эффектов и музыки.
233. (КОНТЕНТ) Реализуйте команды mute
, skills
, start
, exit
и device
. mute
отключает все звуки. skills
выполняет переход к комнате SkillTree. start
создаёт ChooseShipModule, а затем начинает игру после выбора игроком корабля. exit
выполняет выход из игры.
КОНЕЦ
И на этом мы закончили с консолью. С помощью всего трёх концепций (строк, строк ввода и модулей) мы можем многое сделать и добавить игровому процессу соли. Следующая часть будет последней, в ней мы рассмотрим различные аспекты, не подошедшие ни к одной из предыдущих частей.
Часть 15: Финал
Введение
В этой последней части мы поговорим о некоторых темах, которые не сочетаются ни с одной из предыдущих частей, но необходимы для готовой игры. Мы рассмотрим следующие темы: сохранение и загрузка данных, достижения, шейдеры и звук.
Сохранение и загрузка
Поскольку эта игра не требует сохранять никаких данных уровней, сохранение и загрузка становятся очень простыми операциями. Для них мы будем использовать библиотеку bitser и две её функции: dumpLoveFile
и loadLoveFile
. Эти функции будут сохранять и загружать любые данные в файл/из файла, которые мы им передадим, с помощью love.filesystem
. Как говорится по ссылке, место сохранения файлов зависит от операционной системы. В Windows файл будет сохраняться в C:UsersuserAppDataRoamingLOVE
. Для изменения места сохранения мы можем использовать love.filesystem.setIdentity
. Если мы изменим значение на BYTEPATH
, то файл сохранения будет сохраняться в C:UsersuserAppDataRoamingBYTEPATH
.
Как бы то ни было, нам понадобятся всего две функции: save
и load
. Они будут определены в main.lua
. Давайте начнём с функции сохранения:
function save()
local save_data = {}
-- Set all save data here
bitser.dumpLoveFile('save', save_data)
end
Функция сохранения достаточно проста. Мы создадим новую таблицу save_data
и поместим в неё все данные, которые нужно сохранять. Например, если мы хотим сохранить количество имеющихся у игрока очков навыков, то мы просто напишем save_data.skill_points = skill_points
, то есть в save_data.skill_points
будет храниться значение, содержащееся в глобальной переменной skill_points
. То же самое относится и ко всем другим типам данных. Однако важно ограничивать себя сохранением значений и таблиц значений. Сохранение объектов целиком, изображений и других типов более сложных данных скорее всего не сработает.
После добавления всего, что мы хотим сохранить, в save_data
, мы просто вызываем bitser.dumpLoveFile
и сохраняем все эти данные в файл 'save'
. При в C:UsersuserAppDataRoamingBYTEPATH
создастся файл save
, и когда этот файл будет существовать, туда сохранится вся необходимая нам для сохранения информация. Мы можем вызывать эту функцию при закрытии игры или при завершении раунда, это уже решать вам. Единственная проблема, которую я здесь могу увидеть, заключается в том, что при сохранении только в конце игры в случае сбоя программы прогресс игрока скорее всего не будет сохранён.
Теперь перейдём к функции загрузки:
function load()
if love.filesystem.exists('save') then
local save_data = bitser.loadLoveFile('save')
-- Load all saved data here
else
first_run_ever = true
end
end
Функция загрузки работает похожим образом, но в обратном направлении. Мы вызываем bitser.loadLoveFile
с именем сохранённого файла (save
), а затем помещаем все данные внутрь локальной таблицы save_data
. Записав все сохранённые данные в эту таблицу, мы можем присваивать их соответствующим переменным. Например, если мы хотим загрузить очки навыков игрока, то мы напишем skill_points = save_data.skill_points
, то есть мы присваиваем сохранённые очки навыков нашей глобальной переменной очков навыков.
Кроме того, для правильной работы функции загрузки требуется дополнительная логика. Если игрок запускает игру впервые, то файл сохранения ещё не существует, то есть при попытке его загрузки программа вывалится. Чтобы устранить эту ошибку, нам нужно проверять, существует ли файл, с помощью love.filesystem.exists
и загружать его, только если он есть. Если его нет, то мы просто задаём глобальной переменной first_run_ever
значение true. Эта переменная полезна, потому что обычно при первом запуске игры нам нужно выполнить некоторые дополнительные действия, например, запуск туториала или отображение сообщения. Функция загрузки будет вызываться один раз в love.load
при загрузке игры. Важно, чтобы эта функция вызывалась после файла globals.lua
, потому что в ней мы переписываем глобальные переменные.
И на этом с сохранением/загрузкой мы закончили. То, что на самом деле нужно сохранять и загружать, мы оставим в качестве упражнения, потому что это зависит от выбранных вами реализуемых аспектов. Например, если вы реализуете дерево навыков в точности, как в части 13, то вам скорее всего потребуется сохранять таблицу bought_node_indexes
, потому что в ней хранятся все купленные игроком узлы.
Достижения
Из-за простоты игры достижения реализовать тоже очень просто (по крайней мере, в сравнении со всем остальным). У нас будет обычная глобальная таблица под названием achievements
. И в этой таблице будут храниться ключи, представляющие собой название достижения, и значения, определяющие, разблокировано ли достижение. Например, если у нас есть достижение '50K'
, разблокируемое, когда игрок набирает за раунд 50 000 очков, то если это достижение разблокировано, achievements['50K']
будет иметь значение true, а в противном случае — false.
Чтобы показать на примере, как это работает, давайте создадим достижение 10K Fighter
, разблокируемое, когда игрок набирает 10 000 очков на корабле Fighter. Чтобы реализовать это, нам достаточно присваивать achievements['10K Fighter']
значение true, когда раунд заканчивается, количество очков больше 10K, а игроком выбран корабль Fighter. Это выглядит так:
function Stage:finish()
timer:after(1, function()
gotoRoom('Stage')
if not achievements['10K Fighter'] and score >= 10000 and device = 'Fighter' then
achievements['10K Fighter'] = true
-- Do whatever else that should be done when an achievement is unlocked
end
end)
end
Как вы видите, кода очень немного. Единственное, что нам нужно — сделать так, чтобы каждое достижение срабатывало только один раз, и мы реализуем это, сначала проверяя, было ли достижение уже разблокировано. Если нет, то можно продолжать.
Пока я не знаком с работой системы достижений Steam, но предполагаю, что мы можем вызывать какую-то функцию или набор функций для разблокирования достижения игрока. Если это так, то мы будем вызывать эту функцию здесь, после того, как присвоим achievements['10K Fighter']
значение true. Стоит также не забывать, что достижения нужно сохранять и загружать, поэтому важно добавить в функции save
и load
соответствующий код.
Шейдеры
Пока в игре я использовал около трёх шейдеров, но рассмотрю только один. Однако поскольку другие используют тот же «фреймворк», их можно выводить на экран аналогичным образом, даже несмотря на то, что содержимое каждого шейдера сильно отличается. Кроме того, я не большой спец по шейдерам и скорее всего делаю множество глупых ошибок, то есть существуют способы реализации получше, чем те, о которых я расскажу. Для меня изучение шейдеров было, наверно, самой сложной частью разработки игр, и я пока освоил их не так хорошо, как остальную часть кодовой базы.
Мы реализуем простой шейдер RGB-смещения и применим его только к некоторым сущностям игры. По сути, пиксельные шейдеры работают следующим образом: мы пишем какой-то код, и этот код применяется ко всем пикселям текстуры, передаваемой в шейдер. Об основах программирования шейдеров можно прочитать здесь.
Одна из проблем, с которой я столкнулся при попытке применения шейдера к разным объектам, заключается в том, что нельзя применять его непосредственно в коде объекта. По какой-то причине (более опытные люди могут точно сказать, в чём же тут дело), пиксельные шейдеры не применяются правильно при использовании простых примитивов наподобие линий, прямоугольников и т.д. И даже если бы мы использовали вместо простых фигур спрайты, шейдер RGB-смещения всё равно не применялся бы нужным нам образом, потому что эффект требует, чтобы мы выходили за пределы спрайта. Но поскольку пиксельный шейдер применяется только к пикселям в текстуре, то если мы применим его, он будет считывать пиксели внутри границ спрайта, поэтому эффект не будет работать.
Чтобы решить эту проблему, я выбрал способ отрисовки объектов, к которым я хочу применить эффект X, на новом холсте, с последующим применением пиксельного шейдера ко всему этому холсту. В подобной игре, где порядок отрисовки практически не важен, у такого способа не было недостатков. Однако в игре, в которой порядок отрисовки важен (например, в 2,5D-игре с видом сверху) реализация становится немного более сложной, поэтому она не является общим решением.
rgb_shift.frag
Прежде, чем перейдём к кодированию всего этого, давайте сначала разберёмся с самим пиксельным шейдером, потому что он очень прост:
extern vec2 amount;
vec4 effect(vec4 color, Image texture, vec2 tc, vec2 pc) {
return color*vec4(Texel(texture, tc - amount).r, Texel(texture, tc).g,
Texel(texture, tc + amount).b, Texel(texture, tc).a);
}
Я поместил его в файл rgb_shift.frag
в папке resources/shaders
, и загружаю его в комнате Stage
с помощью love.graphics.newShader
. Точка входа для всех пиксельных шейдеров — это функция effect
. Эта функция получает вектор color
, являющийся схожим с love.graphics.setColor
, только вместо интервала 0-255 в нём используется интервал 0-1. То есть если текущий цвет имеет значение 255, 255, 255, 255, то этот vec4 будет иметь значения 1.0, 1.0, 1.0, 1.0. Далее он получает texture
, к которой применяется шейдер. Эта текстура может быть холстом, спрайтом или любым объектом LÖVE, который можно отрисовывать. Пиксельный шейдер автоматически проходит по всем пикселям в этой текстуре и применяет к каждому пикселю код внутри функции effect
, заменяя значение пикселя возвращённым значением. Значения пикселей всегда являются объектами vec4, где 4 — это компоненты красного, зелёного, синего и альфа-каналов.
Третий аргумент tc
обозначает координату текстуры. Координаты текстур находятся в интервале от 0 до 1 и представляют собой позицию текущего пикселя внутри текстуры. Верхний левый угол — это 0, 0
, а нижний правый — 1, 1
. Мы будем использовать его вместе с функцией texture2D
(которая в LÖVE называется Texel
) для получения содержимого текущего пикселя. Четвёртый аргумент pc
представляет собой координату пикселя в экранном пространстве. В шейдере мы не будем его использовать.
Наконец, последнее, что нам нужно знать прежде чем получить функцию эффекта — как мы можем передавать значения в шейдер, чтобы он мог ими манипулировать. В нашем случае мы передаём вектор vec2 amount
, который будет управлять размером эффекта RGB-сдвига. Значения можно передавать с помощью функции send
.
Единственная строка, которая создаёт весь эффект, выглядит так:
return color*vec4(
Texel(texture, tc - amount).r,
Texel(texture, tc).g,
Texel(texture, tc + amount).b,
Texel(texture, tc).a);
Здесь мы используем функцию Texel
для поиска пикселей. Но мы хотим не только искать пиксель в текущей позиции, а ещё и пиксели в соседних позициях, чтобы это действительно был RGB-сдвиг. Этот эффект сдвигает различные цветовые каналы (в нашем случае красный и синий) в разных направлениях, что придаёт всему «глитчевый» внешний вид. То есть по сути мы ищем пиксель в позиции tc - amount
и tc + amount
, затем берём значения красного и синего этого пикселя вместе с значением зелёного исходного пикселя, а затем выводим их. Мы могли бы внести здесь небольшую оптимизацию, потому что мы получаем одну позицию дважды (для зелёного и альфа-компонентов), но для столь простого шейдера это не сильно важно.
Выборочная отрисовка
Так как мы хотим применять этот пиксельный шейдер только к определённым сущностям, нам нужно найти способ отрисовки только конкретных сущностей. Проще всего это сделать, пометив каждую сущность тегом, а затем создать в объекте Area
альтернативную функцию draw
, которая будет отрисовывать объекты только с этим тегом. Присвоение тега выглядит так:
function TrailParticle:new(area, x, y, opts)
TrailParticle.super.new(self, area, x, y, opts)
self.graphics_types = {'rgb_shift'}
...
end
Тогда создание новой функции draw, которая будет отрисовывать объекты только с определёнными тегами, выглядит так:
function Area:drawOnly(types)
table.sort(self.game_objects, function(a, b)
if a.depth == b.depth then return a.creation_time < b.creation_time
else return a.depth < b.depth end
end)
for _, game_object in ipairs(self.game_objects) do
if game_object.graphics_types then
if #fn.intersection(types, game_object.graphics_types) > 0 then
game_object:draw()
end
end
end
end
То есть точно так же, как обычная функция Area:draw
, только с дополнительнйо логикой. Мы используем intersection
, чтобы определить, есть ли общие элементы в передаваемых таблицах graphics_types
и types
. Например, если мы решим, что хотим отрисовывать только объекты типа rgb_shift
, то будем вызывать area:drawOnly({'rgb_shift'})
, то есть эта передаваемая таблица будет проверяться с graphics_types
каждого объкта. Если у них есть какие-то схожие элементы, то #fn.intersection
будет больше нуля, то есть мы можем отрисовать объект.
Аналогичным образом мы хотим реализовать функцию Area:drawExcept
, поскольку всё, что мы рисуем на одном холсте, нам не нужно отрисовывать на другом, то есть на каком-то этапе нам нужно исключить из отрисовки определённые типы объектов. Это выглядит так:
function Area:drawExcept(types)
table.sort(self.game_objects, function(a, b)
if a.depth == b.depth then return a.creation_time < b.creation_time
else return a.depth < b.depth end
end)
for _, game_object in ipairs(self.game_objects) do
if not game_object.graphics_types then game_object:draw()
else
if #fn.intersection(types, game_object.graphics_types) == 0 then
game_object:draw()
end
end
end
end
Здесь мы отрисовываем объект, если у него не определено graphics_types
, а также если его пересечение с таблицей types
равно 0, то есть его тип графики не является одним из тех, которые определены вызывающей функцией.
Холсты + шейдеры
С учётом всего этого мы можем приступить к реализации эффекта. Пока мы реализуем его только для объекта TrailParticle
, то есть RGB-сдвиг будет создаваться для следа корабля игрока и снарядов. Основной способ, которым мы можем применить RGB-сдвиг к объектам наподобие TrailParticle, выглядит так:
function Stage:draw()
...
love.graphics.setCanvas(self.rgb_shift_canvas)
love.graphics.clear()
camera:attach(0, 0, gw, gh)
self.area:drawOnly({'rgb_shift'})
camera:detach()
love.graphics.setCanvas()
...
end
Это выглядит похожим на то, как мы отрисовываем сущности обычным способом, только вместо отрисовки на холсте main_canvas
мы рисуем на созданном rgb_shift_canvas
. И, что более важно, мы отрисовываем только объекты с тегом 'rgb_shift'
. Таким образом, на этом холсте будут содержаться только нужные нам объекты, к которым мы позже сможем применить пиксельный шейдер. Я использовал похожую идею для отрисовки эффектов Shockwave
и Downwell
.
Завершив отрисовку на холсты отдельных эффектов, мы можем отрисовать основную игру на main_canvas
, за исключением сущностей, уже отрисованных на других холстах. Это будет выглядеть так:
function Stage:draw()
...
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
camera:attach(0, 0, gw, gh)
self.area:drawExcept({'rgb_shift'})
camera:detach()
love.graphics.setCanvas()
...
end
И наконец мы можем применить нужные нам эффекты. Мы сделаем это, отрисовав rgb_shift_canvas
на другом холсте под названием final_canvas
, но на этот раз применив пиксельный шейдер RGB-сдвига. Это выглядит следующим образом:
function Stage:draw()
...
love.graphics.setCanvas(self.final_canvas)
love.graphics.clear()
love.graphics.setColor(255, 255, 255)
love.graphics.setBlendMode("alpha", "premultiplied")
self.rgb_shift:send('amount', {
random(-self.rgb_shift_mag, self.rgb_shift_mag)/gw,
random(-self.rgb_shift_mag, self.rgb_shift_mag)/gh})
love.graphics.setShader(self.rgb_shift)
love.graphics.draw(self.rgb_shift_canvas, 0, 0, 0, 1, 1)
love.graphics.setShader()
love.graphics.draw(self.main_canvas, 0, 0, 0, 1, 1)
love.graphics.setBlendMode("alpha")
love.graphics.setCanvas()
...
end
С помощью функции send
мы можем изменять значение переменной amount
, чтобы она соответствовала величине сдвига, который должен применять шейдер. Так как координаты текстур внутри пиксельного шейдера находятся в интервале значений от 0 и 1, мы хотим разделить передаваемые величины на gw
и gh
. То есть, если мы, например, хотим выполнить сдвиг на 2 пикселя, то rgb_shift_mag
будет равно 2, но передаваемое значение будет равно 2/gw b 2/gh, поскольку внутри пиксельного шейдера 2 пикселя влево/вправо представлены этим маленьким значением, а не 2. Также нам нужно отрисовать холст main на холст final, потому что холст final должен содержать всё, что мы хотим отрисовать.
Наконец, снаружи этого кода мы можем отрисовать холст final на экран:
function Stage:draw()
...
love.graphics.setColor(255, 255, 255)
love.graphics.setBlendMode("alpha", "premultiplied")
love.graphics.draw(self.final_canvas, 0, 0, 0, sx, sy)
love.graphics.setBlendMode("alpha")
love.graphics.setShader()
end
Мы могли бы отрисовывать всё непосредственно на экран, а не предварительно в final_canvas
, но если бы нам нужно было применить к экрану другой полноэкранный шейдер, например distortion
, то проще это сделать, когда всё надлежащим образом сохранено в холст.
И всё это в результате будет выглядеть так:
Как и ожидалось, RGB-сдвиг применяется только к следу корабля, придавая ему нужный нам «глитчевый» вид.
Звук
Я не большой специалист по звуку, поэтому хотя в нём можно сделать много интересных и сложных вещей, я буду придерживаться того, что знаю, то есть простого воспроизведения звуков в нужный момент. Мы можем сделать это с помощью ripple.
У этой библиотеки достаточно простой API и по сути она сводится к загрузке звуков с помощью ripple.newSound
и их воспроизведению вызовом :play
для возвращённого объекта. Например, если мы хотим воспроизвести звук стрельбы, когда игрок стреляет, то можем сделать нечто подобное:
-- in globals.lua
shoot_sound = ripple.newSound('resources/sounds/shoot.ogg')
function Player:shoot()
local d = 1.2*self.w
self.area:addGameObject('ShootEffect', ...
shoot_sound:play()
...
end
Таким очень простым способом мы можем вызывать :play
, когда хотим, чтобы воспроизводился звук. В библиотеке есть и другие полезные возможности, например, изменение тона звука, зацикленное воспроизведение звуков, создание тегов для изменения свойств всех звуков с определённым тегом, и так далее. В своей игре я добавил ещё немного эффектов, но здесь я не буду их рассматривать. Если вы купили туториал, то всё это находится в файле sound.lua
.
КОНЕЦ
И на этом мой туториал завершается. Ни в коем случае нельзя считать, что мы полностью раскрыли все темы, но самые важные части игры рассмотрены. Если вы внимательно изучали статьи, то должны достаточно хорошо разобраться с кодовой базой, а если приобрели туториал, то можете прочитать весь исходный код, чтобы гораздо лучше понять, что здесь происходит.
Надеюсь, этот туториал оказался полезным и дал вам представление о том, что же представляет собой разработка настоящей игры и как перейти от нуля к готовому результату. В идеале всё изученное вы должны использовать для создания собственной игры с нуля, а не изменять мою, потому что это гораздо лучшее упражнение для развития навыков «начинания с нуля». Обычно при начале нового проекта я почти всегда копирую код, который пригождался мне в нескольких проекта. Обычно это большая часть кода «движка», который мы рассматривали в частях 1-5.
Если вам понравится эта серия туториалов, то вы можете простимулировать меня к написанию чего-то подобного в будущем:
Купив туториал на itch.io, вы получите доступ к полному исходному коду игры, к ответам на упражения из частей 1-9, к коду, разбитому по частям туториала (код будет выглядеть так, как должен выглядеть в конце каждой части) и к ключу игры в Steam.
Автор: PatientZero