Главная » Хабрахабр » [Перевод] Создание игры на Lua и LÖVE — 7

[Перевод] Создание игры на Lua и LÖVE — 7

image

Оглавление

Оглавление

  • Статья 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. Консоль реализовать гораздо проще, чем всё остальное, потому что в итоге она сводится к выводу на экран текста. Вот, как это выглядит:

GIF

Комната Console будет состоять из трёх разных типов объектов: строк, строк ввода и модулей. Строки — это просто обычные цветные строки текста, отображаемые на экране. Например, в показанном выше примере ":: running BYTEPATH..." будет являться строкой. С точки зрения структуры данных это будет просто таблица, хранящая позицию строки, её текст и цвета.
Строки ввода — это строки, в которые игрок может что-то вводить. В показанном выше примере это те строки, в которых есть слово «arch». При вводе определённых команд в консоль эти команды будут выполняться и создавать новые строки или модули. С точки зрения структуры данных строки ввода будут походить на простые строки, только с дополнительной логикой для считывания ввода, когда последняя строка, добавленная в комнату, является строкой ввода.

Это целый набор элементов, которые появляются, когда игроку, например, нужно выбрать корабль. Наконец, модуль — это специальный объект, позволяющий игроку выполнять более сложные действия, чем простой ввод команд. Все эти модули сами по себе тоже будут являться объектами, а комната Console соответствующим образом будет обрабатывать их создание и удаление. Такие объекты могут создаваться различными командами, то есть, например, когда игрок хочет изменить громкость звука в игре, он должен ввести «volume», после чего откроется модуль Volume, в котором можно будет выбрать уровень громкости.

Строки

Давайте начнём со строк. Мы можем определить строку таким образом:


}

То есть у неё есть позиция x, y, а также атрибут text. Этот атрибут текста является объектом Text. Мы будем использовать объекты Text из LÖVE, потому что с их помощью легко можно определять цветной текст. Но прежде, чем мы сможем добавлять в комнату Console строки, нам нужно создать её, так что давайте займёмся этим. В основе своей эта задача похожа на создание комнаты SkillTree.

Также мы добавим функцию addLine, которая будет добавлять новую текстовую строку в таблицу lines: Мы добавим таблицу lines, в которой будут храниться все текстовые строки, а затем в функции draw мы обойдём всю эту таблицу и отрисуем каждую строку.

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.

И всё это будет выглядеть так:

GIF

Модули

Модули — это объекты. в которых содержится логика, позволяющая игроку делать что-то в консоли. Например, ResolutionModule, который мы реализуем, позволит игроку менять разрешение в игре. Мы отделим модули от остальной части кода комнаты Console, потому что их логика может быть довольно сложной, и логично выделить их в отдельные объекты. Мы реализуем модуль, который выглядит так:

GIF

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

(КОНТЕНТ) Реализуйте модуль AchievementsModule. 228. Достижения мы рассмотрим в следующей части туториала, поэтому вернитесь к этому упражнению позже! Он показывает все достижения и требования, необходимые для их разблокировки.

(КОНТЕНТ) Реализуйте модуль ClearModule. 229. Сохранение/загрузка данных тоже будут рассмотрены в следующей статье, поэтому к этому упражнению также можно вернуться позже. Этот модуль позволяет удалять все сохранённые данные или очищать дерево навыков.

(КОНТЕНТ) Реализуйте модуль ChooseShipModule. 230. Этот модуль позволяет игроку выбирать и разблокировать корабли для игрового процесса.

(КОНТЕНТ) Реализуйте модуль HelpModule. 231. В игре будет поддерживаться геймпад, поэтому заставлять игроков вводить что-то не очень хорошо. Он отображает все доступные команды и позволяет игроку выбирать команду, не вводя текст.

(КОНТЕНТ) Реализуйте модуль VolumeModule. 232. Он позволяет игроку выбирать громкость звуковых эффектов и музыки.

(КОНТЕНТ) Реализуйте команды mute, skills, start, exit и device. 233. skills выполняет переход к комнате SkillTree. mute отключает все звуки. exit выполняет выход из игры. start создаёт ChooseShipModule, а затем начинает игру после выбора игроком корабля.

КОНЕЦ

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

Часть 15: Финал

Введение

В этой последней части мы поговорим о некоторых темах, которые не сочетаются ни с одной из предыдущих частей, но необходимы для готовой игры. Мы рассмотрим следующие темы: сохранение и загрузка данных, достижения, шейдеры и звук.

Сохранение и загрузка

Поскольку эта игра не требует сохранять никаких данных уровней, сохранение и загрузка становятся очень простыми операциями. Для них мы будем использовать библиотеку bitser и две её функции: dumpLoveFile и loadLoveFile. Эти функции будут сохранять и загружать любые данные в файл/из файла, которые мы им передадим, с помощью love.filesystem. Как говорится по ссылке, место сохранения файлов зависит от операционной системы. В Windows файл будет сохраняться в C:\Users\user\AppData\Roaming\LOVE. Для изменения места сохранения мы можем использовать love.filesystem.setIdentity. Если мы изменим значение на BYTEPATH, то файл сохранения будет сохраняться в C:\Users\user\AppData\Roaming\BYTEPATH.

Они будут определены в main.lua. Как бы то ни было, нам понадобятся всего две функции: save и load. Давайте начнём с функции сохранения:

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. То же самое относится и ко всем другим типам данных. Однако важно ограничивать себя сохранением значений и таблиц значений. Сохранение объектов целиком, изображений и других типов более сложных данных скорее всего не сработает.

При в C:\Users\user\AppData\Roaming\BYTEPATH создастся файл save, и когда этот файл будет существовать, туда сохранится вся необходимая нам для сохранения информация. После добавления всего, что мы хотим сохранить, в save_data, мы просто вызываем bitser.dumpLoveFile и сохраняем все эти данные в файл '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, то есть мы присваиваем сохранённые очки навыков нашей глобальной переменной очков навыков.

Если игрок запускает игру впервые, то файл сохранения ещё не существует, то есть при попытке его загрузки программа вывалится. Кроме того, для правильной работы функции загрузки требуется дополнительная логика. Если его нет, то мы просто задаём глобальной переменной first_run_ever значение true. Чтобы устранить эту ошибку, нам нужно проверять, существует ли файл, с помощью love.filesystem.exists и загружать его, только если он есть. Функция загрузки будет вызываться один раз в love.load при загрузке игры. Эта переменная полезна, потому что обычно при первом запуске игры нам нужно выполнить некоторые дополнительные действия, например, запуск туториала или отображение сообщения. Важно, чтобы эта функция вызывалась после файла globals.lua, потому что в ней мы переписываем глобальные переменные.

То, что на самом деле нужно сохранять и загружать, мы оставим в качестве упражнения, потому что это зависит от выбранных вами реализуемых аспектов. И на этом с сохранением/загрузкой мы закончили. Например, если вы реализуете дерево навыков в точности, как в части 13, то вам скорее всего потребуется сохранять таблицу bought_node_indexes, потому что в ней хранятся все купленные игроком узлы.

Достижения

Из-за простоты игры достижения реализовать тоже очень просто (по крайней мере, в сравнении со всем остальным). У нас будет обычная глобальная таблица под названием achievements. И в этой таблице будут храниться ключи, представляющие собой название достижения, и значения, определяющие, разблокировано ли достижение. Например, если у нас есть достижение '50K', разблокируемое, когда игрок набирает за раунд 50 000 очков, то если это достижение разблокировано, achievements['50K'] будет иметь значение true, а в противном случае — false.

Чтобы реализовать это, нам достаточно присваивать achievements['10K Fighter'] значение true, когда раунд заканчивается, количество очков больше 10K, а игроком выбран корабль Fighter. Чтобы показать на примере, как это работает, давайте создадим достижение 10K Fighter, разблокируемое, когда игрок набирает 10 000 очков на корабле 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

Как вы видите, кода очень немного. Единственное, что нам нужно — сделать так, чтобы каждое достижение срабатывало только один раз, и мы реализуем это, сначала проверяя, было ли достижение уже разблокировано. Если нет, то можно продолжать.

Если это так, то мы будем вызывать эту функцию здесь, после того, как присвоим achievements['10K Fighter'] значение true. Пока я не знаком с работой системы достижений Steam, но предполагаю, что мы можем вызывать какую-то функцию или набор функций для разблокирования достижения игрока. Стоит также не забывать, что достижения нужно сохранять и загружать, поэтому важно добавить в функции 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 — это компоненты красного, зелёного, синего и альфа-каналов.

Координаты текстур находятся в интервале от 0 до 1 и представляют собой позицию текущего пикселя внутри текстуры. Третий аргумент tc обозначает координату текстуры. Мы будем использовать его вместе с функцией texture2D (которая в LÖVE называется Texel) для получения содержимого текущего пикселя. Верхний левый угол — это 0, 0, а нижний правый — 1, 1. В шейдере мы не будем его использовать. Четвёртый аргумент 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, то проще это сделать, когда всё надлежащим образом сохранено в холст.

И всё это в результате будет выглядеть так:

GIF

Как и ожидалось, 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.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

«Чтение на выходные»: 25 материалов для начинающих любителей винила

Сегодня мы подобрали материалы из «Мира Hi-Fi» специально для начинающих и всех, кто хотел бы познакомиться с экосистемой винила, подобрать проигрыватель и разобраться с настройкой. Фото Best Picko / CC Выбор проигрывателя Назад к винилу — вперед к настоящему звуку. ...

[Перевод] Оптимизация рендеринга сцены из диснеевского мультфильма «Моана». Часть 2

Вдохновлённый первой победой при парсинге с помощью описания сцены острова из мультфильма «Моана» компании Disney, я далее углубился в изучение использования памяти. Можно было сделать ещё очень многое со временем выполнения, но я решил, что будет полезно сначала разведать обстановку. ...