Pull to refresh

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

Reading time 22 min
Views 4.2K
Original author: SSYGEN
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». При вводе определённых команд в консоль эти команды будут выполняться и создавать новые строки или модули. С точки зрения структуры данных строки ввода будут походить на простые строки, только с дополнительной логикой для считывания ввода, когда последняя строка, добавленная в комнату, является строкой ввода.

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

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

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

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:\Users\user\AppData\Roaming\LOVE. Для изменения места сохранения мы можем использовать love.filesystem.setIdentity. Если мы изменим значение на BYTEPATH, то файл сохранения будет сохраняться в C:\Users\user\AppData\Roaming\BYTEPATH.

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

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

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.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+23
Comments 2
Comments Comments 2

Articles