tree[10] = { name = 'HP', stats = { {'4% Increased HP', 'hp_multiplier' = 0.04} } x = 150, y = 150, links = {4, 6, 8}, type = 'Small', }
(150, 150)
is a suitable position, and the positions in the tree
table of the nodes associated with it are 4, 6, and 8 (the position of the node is 10, since it is defined in the tree[10]
). Thus, we can easily define hundreds of tree nodes, pass this huge table to a certain function that considers it, creates Node objects and associates them in an appropriate way, after which we can apply any necessary logic to the tree.SkillTree
and then use gotoRoom
to go into it at the beginning of the game (because now we will work in it). The basics of this room will be the same as that of the Stage room, so I will assume that you will manage to create it yourself.tree.lua
file, but for now we will do it only by their position. Our goal is to read these nodes from a file and create them in the SkillTree room. We can define them as follows: 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
addGameObject
to add a new game object to the environment. It also means that we will need to track existing objects on our own. In this case, we do this in the nodes
table. The Node
object looks like this: 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
nodes
and call update / draw for each node that is in it, assuming the camera is fixed at position 0, 0
(unlike the Stage room, where it is fixed at gw/2, gh/2
) then it should look like this:current_frame_position - previous_frame_position
. It all looks like this: 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
slightly changed compared to the default functionality , since we work with the canvas in a different way than the library expects. I changed it a long time ago, so I don’t remember why I did it, so I’ll just leave it at that. But if you're curious, then it is worth considering this in more detail and figuring out whether to do it this way, or there is a way to use the default camera module without changes.scale
property when scrolling the mouse wheel up / down: 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'
id, because we want one tween to stop when we start the other. The only thing that remains in this code snippet is the addition of lower and upper scale limits, because we do not want it, for example, to go below.Line
object, and this Line object will receive in its constructors the id
two nodes it connects. id
denotes the index of the node in the tree
object. That is, a node created from tree[2]
will have id = 2
. We can modify the Node object as follows: function Node:new(id, x, y) self.id = id self.x, self.y = x, y end
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
and node_2
. Then we simply draw a line between the positions of these nodes.links
table of each node in the tree. Suppose we have a tree that looks like this: 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}}
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'
mode, otherwise, the lines will be superimposed on the nodes and will be issued a little: 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
mouse is inside the rectangle defined by its width and height. If so, then we set hot
to true, otherwise false. That is, hot
is just a boolean, telling us if the cursor is pointing at a node.camera:attach
and camera:detach
block, because we want the size of this rectangle to remain the same regardless of the scale. 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
will contain a list of parameters for the current node. That is, if we go through the tree[2]
node, then the stats
will matter {'4% Increased HP', 'hp_multiplier', 0.04, '4% Increased Ammo', 'ammo_multiplier', 0.04}
. The parameter table is always divided into three elements. The first is the visual description of the parameter, then comes the variable that changes the Player object, and then the value of this effect. We only need a visual description, that is, we have to go through the table with an increment of 3, which we do in the for loop shown above.font:getWidth
. The maximum width of all our parameters will be stored in the variable max_text_width
, after which we can proceed to drawing the rectangle: 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
, because we do not take into account camera transformations. However, we cannot simply use love.mouse.getPosition
directly, because the canvas is scaled to sx, sy
, that is, the mouse position returned by the LÖVE function is incorrect if the scale of the game is different from 1. Therefore, to get the right value, we need to divide this position on the scale.16 + max_text_width
, which gives us a border of 8 pixels on each side, and a height of font:getHeight() + (#stats/3)*font:getHeight()
. The first element of this formula ( font:getHeight()
) is used for the same purpose as 16 in the width calculation, that is, it gives the value for the border. In our case, the upper and lower bounds of the rectangle will be equal to font:getHeight()/2
. The second part is simply the height occupied by each line of parameters. Since the parameters are grouped in three, it is logical to consider each parameter as #stats/3
, and then multiply this number by the height of the line.8 + mx
, because we decided that there would be a border of 8 pixels on each side. And we also know that the position of the first text in y will be equal to my + font:getHeight()/2
, because we decided that the border at the top and bottom will be equal to font:getHeight()/2
. We only need to figure out how to draw a few lines, but we already know this because we chose the height of the rectangle to be (#stats/3)*font:getHeight()
. This means that each line is drawn 1*font:getHeight()
, 2*font:getHeight()
and so on. It all looks like this: 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}}
bought_node_indexes
table, which will simply contain a bunch of numbers pointing to the tree nodes that have already been bought. In our case, we simply add 1
to it, that is, the tree[1]
will be active. We also need to slightly change nodes and links graphically so that we can more easily see which ones are active and which ones are not. For now, we will simply display blocked nodes in gray (with alpha = 32 instead of 255), and not white: 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
bought_node_indexes = {1}
, we get something like this:bought_node_indexes = {1, 2}
, then we get this: 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}}}
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
we copy it locally to the self.tree
attribute, and then use this attribute. In the objects SkillTree, Node and Line we have to replace the references to the global tree
local attribute tree
SkillTree. We have to do this because we will change the definition of the tree, adding the numbers of certain nodes to the relationship table, and in the general case (for the reasons explained in Part 10), we do not want to change global variables in this way. This means that every time we enter the SkillTree room, we copy the global definition to a local one and use the local definition in the code.ipairs
node.links or {}
inside the call, because a link table can be defined for some nodes. It is also important to note that we do this before creating Node and Line objects, although this is not necessary.links
will contain duplicate values. Depending on the way the table is defined, tree
we will sometimes have nodes bidirectionally, that is, the links will already be where they should be. This is actually not a problem, except that it can lead to the creation of multiple Line objects. To prevent this, we can re-walk the tree and make sure that all tables links
contain only unique values: 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
hovered over the node and the player presses the left mouse button, then we check with the help of the SkillTree object function whether this node can be purchased (we will implement the function below). If it can be bought, we add it to the global table bought_node_indexes
. Here we also make it so that you cannot add a node to this table twice. Although if we had added it several times, it would not change anything and would not cause any bugs.canNodeBeBought
works like this: it passes through the connected nodes to the node that was transferred to it and will check if any of them is inside the table bought_node_indexes
. If so, then this node is connected to the already purchased one, that is, you can buy it: 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
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}}
hp_multiplier
. If we go back to the Player object and see where it is used hp_multiplier
, we will see the following: function Player:setStats() self.max_hp = (self.max_hp + self.flat_hp)*self.hp_multiplier self.hp = self.max_hp ... end
setStats
as a multiplier of base HP, folded with some simple HP value, which we expected. We want the following behavior from the tree: for all nodes inside bought_node_indexes
we apply their parameter to the corresponding player variable. That is, if inside this table there are nodes 2, 3 and 4, then the player must have hp_multiplier
equal to 1.14 (0.04 + 0.06 + 0.04 + base equal to 1). We can relatively simply implement it like this: 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
. As expected, we go through all purchased nodes, and then by their parameters. For each parameter, we take an attribute ( 'hp_multiplier'
) and value (0.04, 0.06), and then apply them to the player. In the example we are discussing, the line is player[attribute] = player[attribute] + value
parsed into player.hp_multiplier = player.hp_multiplier + 0.04
or into player.hp_multiplier = player.hp_multiplier + 0.06
, depending on which node we cycle around. This means that by the end of the external for we will apply all purchased passive skills to player variables.skill_points
that stores the amount of skill points a player has. When a player buys a new node in the skill tree, this variable should decrease by 1. The player should not be able to buy more nodes than he has skill points. A player can buy no more than 100 knots. If necessary, you can slightly change these numbers. For example, in my game, the price of each node increases depending on how many nodes the player has already bought.treeToPlayer
!Source: https://habr.com/ru/post/352896/
All Articles