[Перевод] Создание игры на Lua и LÖVE — 6
Оглавление
- Статья 1
- Часть 1. Игровой цикл
- Часть 2. Библиотеки
- Часть 3. Комнаты и области
- Часть 4. Упражнения
- Статья 2
- Часть 5. Основы игры
- Часть 6. Основы класса Player
- Статья 3
- Часть 7. Параметры и атаки игрока
- Часть 8. Враги
- Статья 4
- Часть 9. Режиссёр и игровой цикл
- Часть 10. Практики написания кода
- Часть 11. Пассивные навыки
- Статья 5
- Часть 12. Другие пассивные навыки
- Статья 5
- Часть 13. Дерево навыков
14. Console
15. Final
Часть 13: Дерево навыков
Введение
В этой части статьи мы сосредоточимся на создании дерева навыков. Вот, как оно выглядит сейчас. Мы не будем располагать каждый узел вручную (это я оставлю в качестве упражнений), а рассмотрим всё необходимое для реализации и правильной работы дерева навыков.
Сначала мы рассмотрим способ задания каждого узла, затем узнаем, как считывать эти определения, создавать необходимые объекты и применять к игроку соответствующие пассивные навыки. Затем мы перейдём к основным объектам (узлам и связям), а потом рассмотрим сохранение и загрузку дерева. А в конце мы реализуем функционал, необходимый для того, чтобы игрок мог тратить очки навыков на узлы дерева.
Дерево навыков
Определить дерево навыков можно множеством различных способов, у каждого из которых есть свои преимущества и недостатки. Мы можем выбрать приблизительно из трёх вариантов:
- Создать редактор дерева навыков, размещающий, связывающий и определяющий параметры каждого узла визуально;
- Создать редактор дерева навыков, размещающий и связывающий узлы визуально, но определяющий параметры каждого узла в текстовом файле;
- Определить всё в текстовом файле.
Я из тех людей, которые стремятся делать реализацию как можно проще и у которых нет проблем с выполнением большого объёма ручной и скучной работы, поэтому обычно я решаю задачи таким способом. То есть из трёх предложенных выше вариантов я склоняюсь к третьему.
Для первых двух вариантов нам понадобилось бы создать визуальный редактор дерева навыков. Чтобы понимать, что это за собой повлечёт, мы должны попробовать перечислить высокоуровневые функции, которыми обязан обладать визуальный редактор дерева навыков:
- Размещение новых узлов
- Связывание узлов вместе
- Удаление узлов
- Перемещение узлов
- Текстовый ввод для определения параметров каждого из узла
Мне пришли в голову только эти высокоуровневые возможности, которые подразумевают и другие функции:
- Узлы скорее всего должны каким-то образом выравниваться друг относительно друга, то есть нам потребуется какая-то система выравнивания. Возможно, узлы могут размещаться только в соответствии с некой системой сеток.
- Связывание, удаление и перемещение узлов подразумевает, что нам потребуется возможность выбора определённых узлов, к которым мы хотим применять такие действия. Это значит, что нам придётся реализовать ещё и функцию выбора узлов.
- Если мы выберем вариант, при котором параметры определяются визуально, то необходим текстовый ввод. Организовать правильную работу элемента TextInput в LÖVE можно несколькими способами, приложив незначительные усилия (https://github.com/keharriso/love-nuklear), поэтому нам только понадобится добавить логику момента отображения элемента текстового ввода и считывания информации из него после записи.
Как вы видите, добавление редактора дерева навыков не кажется большой работой по сравнению с тем, что мы уже сделали. Поэтому если вы хотите выбрать этот вариант, то он вполне жизнеспособен и может в вашем случае улучшить процесс создания дерева навыков. Но как я сказал, обычно у меня не бывает никаких проблем с выполнением больших объёмов ручной и скучной работы, то есть я без проблем могу определить всё в текстовом файле. Поэтому в этой статье мы не будем реализовывать никакого редактора дерева навыков и полностью определим его в текстовом файле.
Определение дерева
Итак, чтобы приступить к определению дерева, нам нужно подумать над тем, из каких элементов состоит узел:
- Текст пассивного навыка:
- Название
- Изменяемые им параметры (6% Increased HP, +10 Max Ammo, и т.д.)
- Положение
- Присоединённые узлы
- Тип узла (обычный, средний или большой)
Пример узла »4% Increased HP» показан в представленном ниже gif:
Он может иметь например такое определение:
tree[10] = {
name = 'HP',
stats = {
{'4% Increased HP', 'hp_multiplier' = 0.04}
}
x = 150, y = 150,
links = {4, 6, 8},
type = 'Small',
}
Мы считаем, что (150, 150)
— это подходящее положение, а позиции в таблице tree
связанных с ним узлов равны 4, 6 и 8 (позиция узла равна 10, поскольку он определён в tree[10]
). Таким образом мы можем запросто определить сотни узлов дерева, передать эту огромную таблицу некой функции, которая её считает, создаст объекты Node и соответствующим образом свяжет их, после чего мы сможем применить к дереву любую нужную логику.
Узлы и камера
Теперь, когда у нас есть представление о том, как будет выглядеть файл дерева, мы можем начинать на этой основе реализацию. Первое, что нам нужно — создать новую комнату SkillTree
и затем использовать gotoRoom
, чтобы перейти в неё в начале игры (потому что сейчас мы будем работать в ней). Основы этой комнаты будут такими же, что и у комнаты Stage, поэтому я буду считать, что вы справитесь с её созданием самостоятельно.
Мы определим в файле tree.lua
два узла, но пока сделаем это только по их позиции. Наша цель заключается в считывании этих узлов из файла и создании их в комнате SkillTree. Мы можем определить их следующим образом:
tree = {}
tree[1] = {x = 0, y = 0}
tree[2] = {x = 32, y = 0}
А считать их мы можем так:
function SkillTree:new()
...
self.nodes = {}
for _, node in ipairs(tree) do table.insert(self.nodes, Node(node.x, node.y)) end
end
Здесь мы считаем, что все объекты нашего SkillTree не будут находиться внутри Area, то есть нам не нужно использовать addGameObject
для добавления в среду нового игрового объекта. Также это значит, что нам нужно будет отслеживать существующие объекты самостоятельно. В этом случае мы делаем это в таблице nodes
. Объект Node
выглядит следующим образом:
Node = Object:extend()
function Node:new(x, y)
self.x, self.y = x, y
end
function Node:update(dt)
end
function Node:draw()
love.graphics.setColor(default_color)
love.graphics.circle('line', self.x, self.y, 12)
end
Это простой объект, совершенно не расширяющий возможности GameObject. И пока мы просто будем отрисовывать в его позиции как круг. Если мы обойдём список nodes
и будем вызывать update/draw для каждого узла, который в нём есть, предполагая, что камера зафиксирована в позиции 0, 0
(в отличие от комнаты Stage, в которой она зафиксирована в gw/2, gh/2
), то это должно выглядеть так:
Как и ожидалось, мы видим здесь оба узла, которые определили в файле дерева.
Камера
Для правильной работы дерева навыков нам нужно немного изменить работу камеры. Пока у нас есть то же поведение, что и в комнате Stage, то есть камера просто привязана к позиции и не делает ничего интересного. Но в SkillTree мы хотим, чтобы камера могла двигаться вместе с мышью, а игрок мог отдалять её (и приближать обратно), чтобы одновременно видеть бОльшую часть дерева.
Для перемещения камеры мы хотим сделать так, чтобы когда игрок удерживает левую клавишу мыши и перетаскивает экран, он двигался в противоположном направлении. То есть когда игрок удерживает кнопку и перемещает мышь вверх мы хотим, чтобы камера двигалась вниз. Проще всего достичь этого отслеживанием позиции мыши в предыдущем кадре, а также в текущем кадре, после чего двигать в направлении, противоположном вектору current_frame_position - previous_frame_position
. Всё это выглядит так:
function SkillTree:update(dt)
...
if input:down('left_click') then
local mx, my = camera:getMousePosition(sx, sy, 0, 0, sx*gw, sy*gh)
local dx, dy = mx - self.previous_mx, my - self.previous_my
camera:move(-dx, -dy)
end
self.previous_mx, self.previous_my = camera:getMousePosition(sx, sy, 0, 0, sx*gw, sy*gh)
end
Если вы проверите, то всё будет работать так, как задумано. Заметьте, что camera:getMousePosition
немного изменился по сравнению с функционалом по умолчанию, поскольку мы работаем с холстом иначе, чем ожидает библиотека. Я изменил это уже давным-давно, поэтому не помню, почему так сделал, поэтому просто оставлю всё как есть. Но если вам любопытно, то стоит рассмотреть это более подробно и разобраться, стоит ли делать таким образом, или существует способ использования модуля камеры по умолчанию без изменений.
Что касается отдаления/приближения, то мы просто изменяем свойство камеры scale
при прокрутке колеса мыши вверх/вниз:
function SKillTree:update(dt)
...
if input:pressed('zoom_in') then
self.timer:tween('zoom', 0.2, camera, {scale = camera.scale + 0.4}, 'in-out-cubic')
end
if input:pressed('zoom_out') then
self.timer:tween('zoom', 0.2, camera, {scale = camera.scale - 0.4}, 'in-out-cubic')
end
end
Здесь мы используем таймер, чтобы масштабирование выполнялось немного плавнее и выглядело лучше. Кроме того, мы даём обоим таймерам одинаковый идентификатор 'zoom'
, потому что хотим, чтобы один tween останавливался, когда мы запускаем другой. Единственное, что осталось в этом фрагменте кода — это добавить ограничения нижнего и верхнего пределов масштаба, потому что мы не хотим, чтобы он, например, опускался ниже.
Связи и параметры
Благодаря предыдущему коду мы сможем добавлять узлы и перемещаться по дереву. Теперь мы рассмотрим соединение узлов и отображение их параметров.
Для связывания узлов мы создадим объект Line
, и этот объект Line будет получать в своих конструкторах id
двух узлов, которые он соединяет. id
обозначает индекс узла в объекте tree
. То есть узел, созданный из tree[2]
, будет иметь id = 2
. Мы можем изменить объект Node следующим образом:
function Node:new(id, x, y)
self.id = id
self.x, self.y = x, y
end
А объект Line мы можем создать так:
Line = Object:extend()
function Line:new(node_1_id, node_2_id)
self.node_1_id, self.node_2_id = node_1_id, node_2_id
self.node_1, self.node_2 = tree[node_1_id], tree[node_2_id]
end
function Line:update(dt)
end
function Line:draw()
love.graphics.setColor(default_color)
love.graphics.line(self.node_1.x, self.node_1.y, self.node_2.x, self.node_2.y)
end
Здесь мы используем переданные идентификаторы для получения соответствующих узлов и сохранения в node_1
и node_2
. Затем мы просто отрисовываем линию между позициями этих узлов.
Теперь в комнате SkillTree мы должны создать объекты Line на основании таблицы links
каждого узла в дереве. Допустим, у нас есть дерево, выглядящее так:
tree = {}
tree[1] = {x = 0, y = 0, links = {2}}
tree[2] = {x = 32, y = 0, links = {1, 3}}
tree[3] = {x = 32, y = 32, links = {2}}
Мы хотим, чтобы узел 1 был соединён с узлом 2, узел 2 был соединён с узлом 1 и 3, а узел 3 соединён с узлом 2. С точки зрения реализации мы должны пройтись по каждому узлу и по каждой из его связей, а затем на основании этих связей создать объекты Line.
function SkillTree:new()
...
self.nodes = {}
self.lines = {}
for id, node in ipairs(tree) do table.insert(self.nodes, Node(id, node.x, node.y)) end
for id, node in ipairs(tree) do
for _, linked_node_id in ipairs(node.links) do
table.insert(self.lines, Line(id, linked_node_id))
end
end
end
Последнее, что мы можем здесь сделать — отрисовать узлы с помощью режима 'fill'
, в противнoм случае линии наложатся на узлы и будут немного выдаваться:
function Node:draw()
love.graphics.setColor(background_color)
love.graphics.circle('fill', self.x, self.y, self.r)
love.graphics.setColor(default_color)
love.graphics.circle('line', self.x, self.y, self.r)
end
И после этого всё должно выглядеть так:
Теперь перейдём к параметрам: допустим, у нас есть такое дерево:
tree[1] = {
x = 0, y = 0, stats = {
'4% Increased HP', 'hp_multiplier', 0.04,
'4% Increased Ammo', 'ammo_multiplier', 0.04
}, links = {2}
}
tree[2] = {x = 32, y = 0, stats = {'6% Increased HP', 'hp_multiplier', 0.04}, links = {1, 3}}
tree[3] = {x = 32, y = 32, stats = {'4% Increased HP', 'hp_multiplier', 0.04}, links = {2}}
Мы хотим добиться следующего:
Вне зависимости от отдаления или приближения, когда пользователь наводит мышь на узел, он должен отображать в небольшом прямоугольнике свои параметры.
Первое, что мы можем сделать — выяснить, навёл ли игрок курсор мыши на узел, или нет. Простейший способ сделать это заключается в проверке того, находится ли позиция мыши внутри прямоугольника, определяющего каждый узел:
function Node:update(dt)
local mx, my = camera:getMousePosition(sx*camera.scale, sy*camera.scale, 0, 0, sx*gw, sy*gh)
if mx >= self.x - self.w/2 and mx <= self.x + self.w/2 and
my >= self.y - self.h/2 and my <= self.y + self.h/2 then
self.hot = true
else self.hot = false end
end
Для каждого узла определены ширина и высота, поэтому мы будем проверять, находится ли позиция мыши mx, my
внутри прямоугольника, определённого его шириной и высотой. Если это так, то мы присваиваем hot
значение true, в противном случае значение false. То есть hot
— это просто boolean, сообщающий нам, наведён ли курсор на узел.
Теперь перейдём к отрисовке прямоугольника. Мы хотим отрисовывать прямоугольник поверх всего, находящегося на экране, поэтому сделать это в классе Node не получится, так как каждый узел отрисовывается последовательно и наш прямоугольник может иногда оказываться под тем или иным узлом. Поэтому я делаю это непосредственно в комнате SkillTree. Также важно то, что мы делаем это за пределами блока camera:attach
и camera:detach
, потому что хотим, чтобы размер этого прямоугольника оставался одинаковым вне зависимости от масштаба.
Его основа выглядит так:
function SkillTree:draw()
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
...
camera:detach()
-- Stats rectangle
local font = fonts.m5x7_16
love.graphics.setFont(font)
for _, node in ipairs(self.nodes) do
if node.hot then
-- Draw rectangle and stats here
end
end
love.graphics.setColor(default_color)
love.graphics.setCanvas()
...
end
Перед отрисовкой прямоугольника нам нужно выяснить его ширину и высоту. Ширина зависит от размера его самого длинного параметра, потому что прямоугольник по определению должен быть больше него. Для этого мы попробуем сделать нечто подобное:
function SkillTree:draw()
...
for _, node in ipairs(self.nodes) do
if node.hot then
local stats = tree[node.id].stats
-- Figure out max_text_width to be able to set the proper rectangle width
local max_text_width = 0
for i = 1, #stats, 3 do
if font:getWidth(stats[i]) > max_text_width then
max_text_width = font:getWidth(stats[i])
end
end
end
end
...
end
Переменная stats
будет содержать список параметров для текущего узла. То есть если мы пройдёмся по узлу tree[2]
, то stats
будет иметь значение {'4% Increased HP', 'hp_multiplier', 0.04, '4% Increased Ammo', 'ammo_multiplier', 0.04}
. Таблица параметров всегда разделена на три элемента. Первый — это визуальное описание параметра, затем идёт переменная, изменяющая объект Player, а потом — величина этого эффекта. Нам нужно только визуальное описание, то есть мы должны пройтись по таблице с инкрементом 3, что и делаем в показанном выше цикле for.
После этого нам нужно найти ширину строки с учётом используемого шрифта, и для этого мы воспользуемся font:getWidth
. Максимальная ширина всех наших параметров будет сохраняться в переменной max_text_width
, после чего мы можем приступать к отрисовке прямоугольника:
function SkillTree:draw()
...
for _, node in ipairs(self.nodes) do
if node.hot then
...
-- Draw rectangle
local mx, my = love.mouse.getPosition()
mx, my = mx/sx, my/sy
love.graphics.setColor(0, 0, 0, 222)
love.graphics.rectangle('fill', mx, my, 16 + max_text_width,
font:getHeight() + (#stats/3)*font:getHeight())
end
end
...
end
Мы хотим отрисовывать прямоугольник в позиции мыши, за исключением того, что нам не нужно использовать camera:getMousePosition
, потому что мы не учитываем трансформации камеры. Однако мы не можем и просто напрямую использовать love.mouse.getPosition
, потому что холст отмасштабирован на sx, sy
, то есть позиция мыши, возвращаемая функцией LÖVE, неправильна, если масштаб игры отличается от 1. Поэтому чтобы получить верное значение, нам нужно разделить эту позицию на масштаб.
Получив верную позицию, мы можем отрисовать прямоугольник с шириной 16 + max_text_width
, что даёт нам границу в 8 пикселей с каждой стороны, и с высотой font:getHeight() + (#stats/3)*font:getHeight()
. Первый элемент этой формулы (font:getHeight()
) используется с той же целью, что и 16 в вычислении ширины, то есть даёт значение для границы. В нашем случае верхняя и нижняя границы прямоугольника будут равны font:getHeight()/2
. Вторая часть — это просто высота, занимаемая каждой строкой параметров. Так как параметры сгруппированы по три, логично считать каждый параметр как #stats/3
, а затем умножать это число на высоту строки.
Последнее, что нужно сделать — отрисовать текст. Мы знаем, что позиция по x всех текстов будет равна 8 + mx
, потому что мы решили, что с каждой стороны будет граница в 8 пикселей. И мы также знаем, что позиция первого текста по y будет равна my + font:getHeight()/2
, потому что мы решили, что граница сверху и снизу будет равна font:getHeight()/2
. Нам осталось только выяснить, как отрисовать несколько строк, но мы уже знаем это, потому что выбрали высоту прямоугольника равной (#stats/3)*font:getHeight()
. Это значит, что каждая строка отрисовывается 1*font:getHeight()
, 2*font:getHeight()
и так далее. Всё это выглядит следующим образом:
function SkillTree:draw()
...
for _, node in ipairs(self.nodes) do
if node.hot then
...
-- Draw text
love.graphics.setColor(default_color)
for i = 1, #stats, 3 do
love.graphics.print(stats[i], math.floor(mx + 8),
math.floor(my + font:getHeight()/2 + math.floor(i/3)*font:getHeight()))
end
end
end
...
end
И так мы получим нужный нам результат. Если посмотреть на весь код в целом, то он выглядит так:
function SkillTree:draw()
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
...
-- Stats rectangle
local font = fonts.m5x7_16
love.graphics.setFont(font)
for _, node in ipairs(self.nodes) do
if node.hot then
local stats = tree[node.id].stats
-- Figure out max_text_width to be able to set the proper rectangle width
local max_text_width = 0
for i = 1, #stats, 3 do
if font:getWidth(stats[i]) > max_text_width then
max_text_width = font:getWidth(stats[i])
end
end
-- Draw rectangle
local mx, my = love.mouse.getPosition()
mx, my = mx/sx, my/sy
love.graphics.setColor(0, 0, 0, 222)
love.graphics.rectangle('fill', mx, my,
16 + max_text_width, font:getHeight() + (#stats/3)*font:getHeight())
-- Draw text
love.graphics.setColor(default_color)
for i = 1, #stats, 3 do
love.graphics.print(stats[i], math.floor(mx + 8),
math.floor(my + font:getHeight()/2 + math.floor(i/3)*font:getHeight()))
end
end
end
love.graphics.setColor(default_color)
love.graphics.setCanvas()
...
end
И я знаю, что если бы увидел подобный код несколько лет назад, то он бы мне очень не понравился. Он выглядит уродливым, неупорядоченным, а иногда и запутанным, но исходя из моего опыта так выглядит стереотипный код отрисовки в разработке игр. Повсюду множество мелких и кажущихся случайными чисел, множество разных проблем вместо целостного куска кода, и так далее. Сегодня я уже привык к такому типу кода и он меня больше не раздражает, и я советую вам тоже привыкнуть к нему, потому что если попробовать сделать его «чище», то, по моему опыту, это приведёт только к ещё более запутанным и менее интуитивным решениям.
Геймплей
Теперь, когда мы можем располагать узлы и соединять их вместе, нам нужно закодировать логику покупки узлов. У дерева будет одна или несколько «точек входа», из которых игрок может начать покупку узлов, и из которых он может покупать только узлы, соседние с уже купленными. Например, в моей схеме есть центральный начальный узел, не дающих никаких бонусов, с которым соединены четыре дополнительных узла, составляющих начало дерева:
Предположим теперь, что у нас есть дерево, изначально выглядящее так:
tree = {}
tree[1] = {x = 0, y = 0, links = {2}}
tree[2] = {x = 48, y = 0, stats = {'4% Increased HP', 'hp_multiplier', 0.04}, links = {3}}
tree[3] = {x = 96, y = 0, stats = {'6% Increased HP', 'hp_multiplier', 0.06}, links = {4}}
tree[4] = {x = 144, y = 0, stats = {'4% Increased HP', 'hp_multiplier', 0.04}}
Первое, что нам нужно — сделать так, чтобы этот узел 1 был уже активирован, а другие не были. Под активированным узлом я подразумеваю то, что он уже куплен игроком и его эффекты применены в геймплее. Поскольку узел 1 не имеет эффектов, то таким образом мы можем создать «исходный узел», из которого будет разрастаться дерево.
Мы сделаем это через глобальную таблицу bought_node_indexes
, в которой просто будет содержаться куча чисел, указывающих на узлы дерева, которые уже куплены. В нашем случае мы просто добавляем в неё 1
, то есть tree[1]
будет активен. Также нам нужно немного изменить узлы и связи графически, чтобы мы могли проще увидеть, какие из них активны, а какие нет. Пока мы просто будем отображать заблокированные узлы серым цветом (с alpha = 32 вместо 255), а не белым:
function Node:update(dt)
...
if fn.any(bought_node_indexes, self.id) then self.bought = true
else self.bought = false end
end
function Node:draw()
local r, g, b = unpack(default_color)
love.graphics.setColor(background_color)
love.graphics.circle('fill', self.x, self.y, self.w)
if self.bought then love.graphics.setColor(r, g, b, 255)
else love.graphics.setColor(r, g, b, 32) end
love.graphics.circle('line', self.x, self.y, self.w)
love.graphics.setColor(r, g, b, 255)
end
И для связей:
function Line:update(dt)
if fn.any(bought_node_indexes, self.node_1_id) and
fn.any(bought_node_indexes, self.node_2_id) then
self.active = true
else self.active = false end
end
function Line:draw()
local r, g, b = unpack(default_color)
if self.active then love.graphics.setColor(r, g, b, 255)
else love.graphics.setColor(r, g, b, 32) end
love.graphics.line(self.node_1.x, self.node_1.y, self.node_2.x, self.node_2.y)
love.graphics.setColor(r, g, b, 255)
end
Мы активируем линию только тогда, когда куплены оба её узла, что выглядит логично. Если мы скажем в конструкторе комнаты SkillTree bought_node_indexes = {1}
, то получим нечто такое:
А если мы скажем, что bought_node_indexes = {1, 2}
, то получим такое:
И всё работает так, как мы и ожидали. Теперь мы хотим добавить логику, необходимую для того, чтобы при нажатии на узел он покупался, если он соединён с другим узлом, который уже был куплен. Определение того, достаточно ли у нас очков навыков для покупки узла и добавление этапа подтверждения перед покупкой узла мы оставим для упражнений.
Прежде чем мы сделаем так, чтобы покупать можно было узлы, соединённые с уже купленными, нам нужно устранить небольшую проблему с тем, как мы определяем наше дерево. Сейчас у нас имеется такое определение:
tree = {}
tree[1] = {x = 0, y = 0, links = {2}}
tree[2] = {x = 48, y = 0, stats = {'4% Increased HP', 'hp_multiplier', 0.04}, links = {3}}
tree[3] = {x = 96, y = 0, stats = {'6% Increased HP', 'hp_multiplier', 0.06}, links = {4}}
tree[4] = {x = 144, y = 0, stats = {'4% Increased HP', 'hp_multiplier', 0.04}}
Одна из проблем такого определения заключается в его однонаправленности. И этого логично было ожидать, поскольку если бы оно не было однонаправленным, то нам бы пришлось много раз определять связи между несколькими узлами:
tree = {}
tree[1] = {x = 0, y = 0, links = {2}}
tree[2] = {x = 48, y = 0, stats = {'4% Increased HP', 'hp_multiplier', 0.04}, links = {1, 3}}
tree[3] = {x = 96, y = 0, stats = {'6% Increased HP', 'hp_multiplier', 0.06}, links = {2, 4}}
tree[4] = {x = 144, y = 0, stats = {'4% Increased HP', 'hp_multiplier', 0.04, links = {3}}}
И хотя в такой реализации нет особо большой проблемы, мы можем сделать так, чтобы достаточно было определять связи только один раз (в любом направлении), а затем применять операцию, автоматически делающую так, что связи определяются в обратном направлении.
Мы можем сделать это, пройдясь по списку всех узлов, а затем по всем связям каждого узла. Для каждой найденной связи мы переходим к соответствующему узлу и добавляем текущий узел к его связям. Например, если мы находимся в узле 1 и видим, что он связан с 2, то мы переходим к узлу 2 и добавляем в его список связей узел 1. Таким образом мы гарантируем, что когда у нас есть определение в одном направлении, то будет определение и в обратном направлении. В коде это выглядит так:
function SkillTree:new()
...
self.tree = table.copy(tree)
for id, node in ipairs(self.tree) do
for _, linked_node_id in ipairs(node.links or {}) do
table.insert(self.tree[linked_node_id], id)
end
end
...
end
Во-первых здесь стоит заметить, что вместо использования глобальной переменной tree
мы копируем её локально в атрибут self.tree
, а затем используем этот атрибут. В объектах SkillTree, Node и Line мы должны заменить ссылки на глобальную tree
локальным атрибутом tree
SkillTree. Мы должны это сделать, потому что будем менять определение дерева, добавляя в таблицу связей номера определённых узлов, и в общем случае (по причинам, объяснённым в части 10) мы не хотим изменять таким образом глобальные переменные. Это значит, что при каждом входе в комнату SkillTree мы копируем глобальное определение в локальное и используем в коде локальное определение.
С учётом этого теперь мы пройдёмся по всем узлам дерева и создадим обратные связи узлов. Важно использовать внутри вызова ipairs
node.links or {}
, потому что у некоторых узлов может быть определена таблица связей. Также важно заметить, что мы делаем это до создания объектов Node и Line, хотя это и необязательно.
Кроме того, здесь нужно заметить, что иногда в таблице links
будут встречаться повторяющиеся значения. В зависимости от способа определения таблицы tree
мы иногда будем располагать узлы двунаправленно, то есть связи уже будут там, где должны быть. На самом деле это не проблема, за исключением того, что это может привести к созданию множественных объектов Line. Чтобы предотвратить это, мы можем повторно пройтись по дереву и сделать так, чтобы во всех таблицах links
содержались только уникальные значения:
function SkillTree:new()
...
for id, node in ipairs(self.tree) do
if node.links then
node.links = fn.unique(node.links)
end
end
...
end
Теперь единственное, что осталось — это сделать так, чтобы при нажатии на узел мы проверяли, соединён ли он с уже купленным узлом:
function Node:update(dt)
...
if self.hot and input:pressed('left_click') then
if current_room:canNodeBeBought(self.id) then
if not fn.any(bought_node_indexes, self.id) then
table.insert(bought_node_indexes, self.id)
end
end
end
...
end
И это будет значить, что если на узел наводят курсор мыши и игрок нажимает левую клавишу мыши, то мы проверяем с помощью функции canNodeBeBought
объекта SkillTree, может ли этот узел быть куплен (функцию мы реализуем ниже). Если он может быть куплен, мы добавляем его к глобальной таблице bought_node_indexes
. Здесь мы также делаем так, чтобы в эту таблицу нельзя было добавить узел дважды. Хотя если бы мы и добавили его несколько раз, это бы ничего не изменило и не вызвало никаких багов.
Функция canNodeBeBought
работает так: она проходит по связанным узлам к узлу, который ей был передан и проверят, находится ли какой-то из них внутри таблицы bought_node_indexes
. Если это так, то этот узел соединён с уже купленным, то есть его можно купить:
function SkillTree:canNodeBeBought(id)
for _, linked_node_id in ipairs(self.tree[id]) do
if fn.any(bought_node_indexes, linked_node_id) then return true end
end
end
Именно этого мы и добивались:
Последняя задача, которую мы рассмотрим — способ применения эффектов выбранных узлов к игроку. Это проще, чем кажется благодаря тому, как мы структурировали всё в частях 11 и 12. Сейчас определение дерева выглядит так:
tree = {}
tree[1] = {x = 0, y = 0, links = {2}}
tree[2] = {x = 48, y = 0, stats = {'4% Increased HP', 'hp_multiplier', 0.04}, links = {3}}
tree[3] = {x = 96, y = 0, stats = {'6% Increased HP', 'hp_multiplier', 0.06}, links = {4}}
tree[4] = {x = 144, y = 0, stats = {'4% Increased HP', 'hp_multiplier', 0.04}}
Как можно заметить, у нас есть второе значение параметра — строка, которая должна указывать на переменную, определённую в объекте Player. В нашем случае это переменная hp_multiplier
. Если мы вернёмся к объекту Player и посмотрим, где используется hp_multiplier
, то увидим следующее:
function Player:setStats()
self.max_hp = (self.max_hp + self.flat_hp)*self.hp_multiplier
self.hp = self.max_hp
...
end
Она используется в функции setStats
в качестве множителя базового HP, сложенного с некоторым простым значением HP, чего мы и ожидали. Мы хотим от дерева следующего поведения: для всех узлов внутри bought_node_indexes
мы применяем их параметр к соответствующей переменной игрока. То есть если внутри этой таблицы есть узлы 2, 3 и 4, то игрок должен иметь hp_multiplier
, равный 1.14 (0.04+0.06+0.04 + основание, равное 1). Мы можем относительно просто реализовать это так:
function treeToPlayer(player)
for _, index in ipairs(bought_node_indexes) do
local stats = tree[index].stats
for i = 1, #stats, 3 do
local attribute, value = stats[i+1], stats[i+2]
player[attribute] = player[attribute] + value
end
end
end
Мы определяем эту функцию в tree.lua
. Как и ожидалось, мы проходим по всем купленным узлам, а потом по их параметрам. Для каждого параметра мы берём атрибут ('hp_multiplier'
) и значение (0.04, 0.06), а затем применяем их к игроку. В обсуждаемом примере строка player[attribute] = player[attribute] + value
парсится в player.hp_multiplier = player.hp_multiplier + 0.04
или в player.hp_multiplier = player.hp_multiplier + 0.06
, в зависимости от того, какой узел мы циклически обходим. Это значит, что к концу внешнего for мы применим все купленные пассивные навыки к переменным игрока.
Важно заметить, что различные пассивные навыки необходимо обрабатывать немного по-разному. Некоторые навыки имеют тип boolean, другие должны применяться к переменным, являющимся объектами Stat, и так далее. Все эти различия необходимо обрабатывать за пределами этой функции.
224. (КОНТЕНТ) Реализуйте очки навыков. У нас есть глобальная переменная skill_points
, в которой хранится количество имеющихся у игрока очков навыков. При покупке игроком нового узла в дереве навыков эта переменная должна уменьшаться на 1. Игрок не должен иметь возможности купить больше узлов, чем у него есть очков навыков. Игрок может купить не более 100 узлов. При необходимости вы можете немного изменить эти числа. Например, в моей игре цена каждого узла увеличивается в зависимости от того, сколько узлов уже купил игрок.
225. (КОНТЕНТ) Реализуйте этап перед покупкой узлов, на котором игрок может отказаться от покупки. Это значит, что игрок может нажимать на узлы, как будто покупает их, но для подтверждения покупки он должен нажать на кнопку «Apply Points». При нажатии на кнопку «Cancel» все выделенные узлы будут отменены. Вот, как это выглядит:
226. (КОНТЕНТ) Реализуйте дерево навыков. Вы можете сделать это дерево любого подходящего вам размера, но очевидно, что чем оно больше, тем больше возможных взаимодействий в нём будет и тем интереснее оно окажется. Для справки: вот, как выглядит моё дерево:
Не забудьте добавить каждому отдельному типу пассивного навыка соответствующие поведения в функции treeToPlayer
!
КОНЕЦ
И на этом статья заканчивается. В следующей части мы рассмотрим комнату Console, а часть после неё будет последней. В последней части мы рассмотрим некоторые аспекты, один из них — загрузка и сохранение. Мы не обсудили один из элементов дерева навыков, а именно сохранение купленных игроком узлов. Мы хотим, чтобы эти узлы оставались купленными на протяжении всего прохождения, а также после закрытия игры, поэтому в последней части рассмотрим эту функцию более подробно.
И как я говорил уже много раз, если вы не хотите, то можете не создавать дерево навыков. Если вы выполняли все действия из предыдущих частей, то у вас уже есть все реализованные пассивные навыки из частей 11 и 12, и вы можете представить их игроку в любом удобном для вас виде. Я решил использовать дерево, но вы можете выбрать что-то иное, если создание огромного дерева вручную кажется вам плохой идеей.