跳转至内容

BYTEPATH #13 - Skill Tree

Introduction

In this article we'll focus on the creation of the skill tree. This is what the skill tree looks like right now. We'll not place each node hand by hand or anything like that (that will be left as an exercise), but we will go over everything needed to make the skill tree happen and work as one would expect.

这一篇我们要专注处理技能树的制作。现在这棵技能树大概长这样。我们不会手把手把每个节点一个一个摆出来,那部分会留作练习;不过,技能树从无到有所需要的核心东西,以及它该怎么正常运作,我们都会完整过一遍。

First we'll focus on how each node will be defined, then on how we can read those definitions, create the necessary objects and apply the appropriate passives to the player. Then we'll move on to the main objects (Nodes and Links), and after that we'll go over saving and loading the tree. And finally the last thing we'll do is implement the functionality needed so that the player can spend his skill points on it.

我们会先看每个节点的数据该怎么定义,再看如何读取这些定义、创建需要的对象,并把正确的被动效果应用到玩家身上。接着会进入几个主要对象,也就是 Nodes 和 Links;然后再讲技能树的保存与读取。最后,我们还会把玩家在这棵树上花费技能点所需要的那套功能补齐。

Skill Tree

There are many different ways we can go about defining a skill tree, each with their advantages and disadvantages. There are roughly three options we can go for:

定义技能树的方式其实有很多种,各有各的优缺点。大致上可以分成三条路:

  • Create a skill tree editor to place, link and define the stats for each node visually;
  • 做一个技能树编辑器,用可视化方式来摆放节点、连线,并配置每个节点的属性;
  • Create a skill tree editor to place and link nodes visually, but define stats for each node in a text file;
  • 也做一个可视化编辑器,但它只负责摆放和连线,节点属性仍然写在文本文件里;
  • Define everything in a text file.
  • 直接把所有内容都写在文本文件里。

I'm someone who likes to keep the implementation of things simple and who has no problem with doing lots of manual and boring work, which means that I'll solve problems in this way generally. When it comes to the options above it means I'll pick the third one.

我个人偏爱实现思路尽量简单,而且对大量手工、重复、甚至有点枯燥的工作也不怎么排斥,所以大多数时候我都会选这种路子。对应到上面三个方案,我会直接选第三种。

The first two options require us to build a visual skill tree editor. To understand what this entails exactly we should try to list the high level features that a visual skill tree editor would have:

前两个方案都要求我们先做一个可视化技能树编辑器。要搞清楚这意味着什么,不妨先列一下这样一个编辑器在高层上至少需要哪些功能:

  • Placing new nodes
  • 放置新节点
  • Linking nodes together
  • 在节点之间建立连接
  • Deleting nodes
  • 删除节点
  • Moving nodes
  • 移动节点
  • Text input for defining each node's stats
  • 用文本输入来定义每个节点的属性

These are pretty much the only high level features I can think of initially, and they imply a few more things:

初步来看,我能想到的高层功能也差不多就是这些。但它们其实又会进一步牵出别的需求:

  • Nodes will probably have to be aligned in relation to each other in some way, which means we'll need some sort of alignment system in place. Maybe nodes can only be placed according to some sort of grid system.
  • 节点之间大概率需要按某种规则对齐,所以还得有一套对齐系统。比如,节点也许只能落在某种网格上。
  • Linking, deleting and moving nodes around implies that we need an ability to select certain nodes to which we want to apply each of those actions. This means node selection is another feature we'd have to implement.
  • 既然要连线、删除、移动节点,那就意味着我们必须先能选中某些节点再对它们操作。所以“节点选择”本身又成了一个额外要实现的功能。
  • If we go for the option where we also define stats visually, then text input is necessary. There are many ways we can get a proper TextInput element working in LÖVE for little work (https://github.com/keharriso/love-nuklear), so we just need to add the logic for when a text input element appears, and how we read information from it once its been written to.
  • 如果我们还打算在可视化编辑器里直接配置属性,那文本输入就必不可少。LÖVE 里其实有不少现成办法可以比较省事地做出靠谱的 TextInput(https://github.com/keharriso/love-nuklear),所以主要工作就变成:何时弹出输入框,以及输入完成后怎么把里面的信息读出来。

As you can see, adding a skill tree editor doesn't seem like a lot of work compared to what we've done so far. So if you want to go for that option it's totally viable and may make the process of building the skill tree better for you. But like I said, I generally have no problem with doing lots of manual and boring work, which means that I have no problem with defining everything in a text file. So for this article we will not do any of those skill tree editor things and we will define the entirety of the skill tree in a text file.

可以看到,和我们前面已经做过的那些事相比,再加一个技能树编辑器其实也没有夸张到工作量爆炸。所以如果你想走那条路,完全可行,而且很可能会让后面搭技能树的过程更舒服。不过就像我刚才说的,我本人对大量手工活并不抗拒,所以我也完全能接受把所有东西都定义在文本文件里。因此,这篇里我们就不做什么技能树编辑器,而是直接用文本文件把整棵树定义出来。

Tree Definition

So to get started with the tree's definition we need to think about what kinds of things make up a node:

在开始定义技能树之前,我们先得想清楚:一个节点到底由哪些信息组成。

  • Passive's text:
  • 被动文本:
    • Name
    • 名称
    • Stats it changes (6% Increased HP, +10 Max Ammo, etc)
    • 它会修改哪些属性(比如 6% Increased HP、+10 Max Ammo 等)
  • Position
  • 位置
  • Linked nodes
  • 相连的节点
  • Type of node (normal, medium or big)
  • 节点类型(normal、medium 或 big)

So, for instance, the "4% Increased HP" node shown in the gif below:

比如下面这张 gif 里的 “4% Increased HP” 节点:

Could have a definition like this:

它的定义完全可以写成这样:

lua
tree[10] = {
    name = 'HP', 
    stats = {
        {'4% Increased HP', 'hp_multiplier' = 0.04}
    }
    x = 150, y = 150,
    links = {4, 6, 8},
    type = 'Small',
}

We're assuming that (150, 150) is a reasonable position, and that the position on the tree table of the nodes linked to it are 4, 6 and 8 (its own position is 10, since its being defined in tree[10]). In this way, we can easily define all the hundreds of nodes in the tree, pass this huge table to some function which will read all this, create Node objects and link those accordingly, and then we can apply whatever logic we want to the tree from there.

这里默认 (150, 150) 是个合适的位置,而和它相连的节点在 tree 表中的索引分别是 4、6、8。它自己是 tree[10],所以自身编号就是 10。按这种方式,我们就能比较轻松地把技能树里上百个节点全部定义出来,再把这张大表交给某个函数,由它去读配置、创建 Node 对象并完成连线。之后要在技能树上套什么逻辑,也就都能顺着做下去了。

Nodes and Camera

Now that we have an idea of what the tree file will look like we can start building from it. The first thing we have to do is create a new SkillTree room and then use gotoRoom to go to it at the start of the game (since that's where we'll be working for now). The basics of this room should be exactly the same as the Stage room, so I'll assume you're capable of doing that with no guidance.

现在我们已经大概知道技能树文件会长什么样了,就可以开始往下搭。第一步,是新建一个 SkillTree room,然后在游戏开始时用 gotoRoom 切进去,因为接下来我们会一直在这个房间里工作。这个 room 的基础结构应该和 Stage room 完全一样,所以这部分我默认你不用我带也能自己搞定。

We'll define two nodes in the tree.lua file but we'll do it only by their position for now. Our goal will be to read those nodes from that file and create them in the SkillTree room. We could define them like this:

我们先在 tree.lua 里只定义两个节点,而且暂时只写它们的位置。目标是把这两个节点从文件里读出来,然后在 SkillTree room 里创建出来。定义可以像这样:

lua
tree = {}
tree[1] = {x = 0, y = 0}
tree[2] = {x = 32, y = 0}

And we could read them like this:

读取方式则可以写成:

lua
function SkillTree:new()
    ...

    self.nodes = {}
    for _, node in ipairs(tree) do table.insert(self.nodes, Node(node.x, node.y)) end
end

Here we assume that all objects for our SkillTree will not be inside an Area, which means we don't have to use addGameObject to add a new game object to the environment, and it also means we need to keep track of existing objects ourselves. In this case we're doing that in the nodes table. The Node object could look like this:

这里我们假设 SkillTree 里的对象都不放进 Area 中。也就是说,我们不用通过 addGameObject 把对象塞进场景环境里;同时也意味着,现有对象要由我们自己手动维护。这里就是通过 nodes 这张表来管理。Node 对象可以长这样:

lua
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

So it's a simple object that doesn't extend from GameObject at all. And for now we'll just draw it at its position as a circle. If we go through the nodes list and call update/draw on each node we have in it, assuming we're locking the camera at position 0, 0 (unlike in Stage where we locked it at gw/2, gh/2) then it should look like this:

所以它就是个很简单的对象,完全没继承 GameObject。目前我们也只是把它按位置画成一个圆而已。如果我们遍历 nodes 列表,对里面每个节点都调用 update/draw,并且把摄像机锁在 0, 0 这个位置上,而不是像 Stage 那样锁在 gw/2, gh/2,那么画面应该会像这样:

And as expected, both the nodes we defined in the tree file are shown here.

和预期一致,刚才在 tree 文件里定义的两个节点都已经显示出来了。

Camera

To make the skill tree work properly we have to change the way the camera works a bit. Right now we should have the same behavior we have from the Stage room, which means that the camera is simply locked to a position but doesn't do anything interesting. But on the SkillTree we want the camera to be able to be moved around with the mouse and for the player to be able to zoom out (and also back in) so he can see more of the tree at once.

要让技能树真正好用,我们还得稍微改一下摄像机的工作方式。现在它应该还是和 Stage room 一样,只是简单地锁在一个位置上,不会做什么更复杂的事。但在 SkillTree 里,我们希望玩家能用鼠标拖动画面,还能缩小和放大视角,这样才能一次看到更大范围的树。

To move it around, we want to make it so that whenever the player is holding down the left mouse button and dragging the screen around, it moves in the opposite direction. So if the player is holding the button and moves the mouse up, then we want to move the camera down. The basic way to achieve this is to keep track of the mouse's position on the previous frame as well as on this frame, and then move the camera in the opposite direction of the current_frame_position - previous_frame_position vector. All that looks like this:

要实现拖动,我们希望在玩家按住鼠标左键拖动画面时,摄像机朝相反方向移动。也就是说,玩家往上拖,摄像机就往下走。最基础的实现方式,就是同时记录鼠标上一帧和当前帧的位置,然后把摄像机按 current_frame_position - previous_frame_position 这个向量的反方向移动。代码如下:

lua
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

And if you try this out it should behave as expected. Note that the camera:getMousePosition has been slightly changed from the default because of the way we're handling our canvases, which is different than what the library expected. I changed this a long long time ago so I don't remember why it is like this exactly, so I'll just go with it. But if you're curious you should look into this more clearly and examine if it needs to be this way, or if there's a way to use the default camera module without any changes that I just didn't figure it out properly.

你可以自己试一下,这样写出来的行为应该就是我们想要的。要注意的是,这里的 camera:getMousePosition默认实现 略有不同,因为我们处理 canvas 的方式和这个库原本预期的不太一样。这段改动是我很久以前写的,现在我自己都记不太清为什么必须这么改了,所以这里就先沿用它。如果你对这个细节感兴趣,完全可以自己继续深挖,看看它到底是不是必须这样,或者有没有办法不用改 camera 模块也能正常工作,只是当时我没搞明白。

As for the zooming in/out, we can simply change the camera's scale properly whenever the user presses wheel up/down:

至于缩放,只要在用户滚动滚轮时适当地修改摄像机的 scale 就行:

lua
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

We're using a timer here so that the zooms are a bit gentle and look better. We're also sharing both timers under the same 'zoom' id, since we want the other tween to stop whenever we start another one. The only thing left to do in this piece of code is to add limits to how low or high the scale can go, since we don't want it to go below 0, for instance.

这里用了 timer,是为了让缩放别那么生硬,看起来更顺一些。两个缩放 tween 还共用了同一个 'zoom' id,因为我们希望用户一旦触发新的缩放,前一个 tween 就立刻停掉。剩下唯一要补的,就是给 scale 加上上下限,比如至少不能缩到 0 以下。

With the previous code we should be able to add nodes and move around the tree. Now we'll focus on linking nodes together and displaying their stats.

有了前面的代码,我们应该已经能把节点显示出来,也能在树上拖动浏览了。接下来要处理的是两件事:节点之间的连线,以及节点属性的显示。

To link nodes together we'll create a Line object, and this Line object will receive in its constructors the id of two nodes that it's linking together. The id represents the index of a certain node on the tree object. So the node created from tree[2] will have id = 2. We can change the Node object like this:

为了实现连线,我们会创建一个 Line 对象。这个对象的构造函数会接收它所连接的两个节点的 id。这里的 id 就是节点在 tree 表里的索引,所以由 tree[2] 创建出来的节点,其 id 就是 2。于是 Node 对象可以改成这样:

lua
function Node:new(id, x, y)
    self.id = id
    self.x, self.y = x, y
end

And we can create the Line object like this:

Line 对象可以这样写:

lua
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

Here we use our passed in ids to get the relevant nodes and store then in node_1 and node_2. Then we simply draw a line between the position of those nodes.

这里就是根据传进来的 id,去取出对应节点并保存在 node_1node_2 里。之后只要在这两个节点的位置之间画一条线就可以了。

Back in the SkillTree room, we need to now create our Line objects based on the links table of each node in the tree. Suppose we now have a tree that looks like this:

回到 SkillTree room 之后,我们就要根据技能树里每个节点的 links 表来批量创建 Line 对象。假设现在树长这样:

lua
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}}

We want node 1 to be linked to node 2, node 2 to be linked to 1 and 3, and node 3 to be linked to node 2. Implementation wise we want to over each node and over each of its links and then create Line objects based on those links.

我们希望节点 1 连到 2,节点 2 连到 1 和 3,节点 3 连到 2。实现上,就是遍历每个节点,再遍历它的所有 links,然后按这些连接关系去创建 Line 对象。

lua
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

One last thing we can do is draw the nodes using the 'fill' mode, otherwise our lines will go over them and it will look off:

最后还可以再补一个小调整:把节点先用 'fill' 模式画实心底色,不然连线会直接穿过节点,看起来不太对劲:

lua
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

And after doing all that it should look like this:

做完这些以后,画面应该会像这样:


As for the stats, supposing we have a tree like this:

再来看属性显示。假设我们的树现在长这样:

lua
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}}

We want to achieve this:

我们想做出这样的效果:

No matter how zoomed in or zoomed out, whenever the user mouses over a node we want to display its stats in a small rectangle.

无论玩家当前把镜头放大还是缩小,只要鼠标悬停在某个节点上,我们都希望用一个小矩形把这个节点的属性显示出来。

The first thing we can focus on is figuring out if the player is hovering over a node or not. The simplest way to do this is to just check is the mouse's position is inside the rectangle that defines each node:

第一步先判断玩家是不是正悬停在某个节点上。最简单的办法,就是检查鼠标位置是否落在定义这个节点的矩形区域内:

lua
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

We have a width and height defined for each node and then we check if the mouse position mx, my is inside the rectangle defined by this width and height. If it is, then we set hot to true, otherwise it will be set to false. hot then is just a boolean that tells us if the node is being hovered over or not.

这里每个节点都有自己的宽高,我们只要看鼠标坐标 mx, my 是否位于这个宽高围成的矩形之中。如果在里面,就把 hot 设为 true;否则就设为 false。也就是说,hot 只是一个很简单的布尔值,用来表示这个节点当前是否正被鼠标悬停。

Now for drawing the rectangle. We want to draw the rectangle above everything else on the screen, so doing this inside the Node class doesn't work, since each node is drawn sequentially, which means that our rectangle would end up behind one or another node sometimes. So we'll do it directly in the SkillTree room. And perhaps even more importantly, we'll do it outside the camera:attach and camera:detach block, since we want the size of this rectangle to remain the same no matter how zoomed in or out we are.

接下来是画这个提示矩形。我们希望它始终盖在画面最上层,所以放在 Node 类里逐个节点去画并不合适,因为节点是顺序绘制的,这样矩形有时候会被别的节点盖住。所以这部分要直接写在 SkillTree room 里。更重要的是,它还必须写在 camera:attachcamera:detach 之外,因为我们希望这个矩形无论镜头缩放多少,屏幕上的显示尺寸都保持一致。

The basics of it looks like this:

基础结构大概是这样:

lua
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

Before drawing the rectangle we need to figure out its width and height. The width is based on the size of the longest stat, since the rectangle has to be bigger than it by definition. To do that we can try something like this:

在真正画矩形之前,我们得先算出它的宽和高。宽度取决于最长的那条属性文本,因为矩形肯定得能把它包住。这个过程可以这样写:

lua
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

The stats variable will hold the list of stats for the current node. So if we're going through the node tree[2], stats would be {'4% Increased HP', 'hp_multiplier', 0.04, '4% Increased Ammo', 'ammo_multiplier', 0.04}. The stats table is divided in 3 elements always. First there's the visual description of the stat, then what variable it will change on the Player object, and then the amount of that effect. We want the visual description only, which means that we should go over this table in increments of 3, which is what we're doing in the for loop above.

stats 变量里存的是当前节点的属性列表。比如,如果我们现在处理的是 tree[2],那么 stats 的内容可能就是 {'4% Increased HP', 'hp_multiplier', 0.04, '4% Increased Ammo', 'ammo_multiplier', 0.04}。这个表永远是每 3 个元素一组:第一个是给玩家看的描述文字,第二个是要修改的 Player 属性名,第三个是具体数值。这里我们只关心展示文本,所以循环步长要按 3 来走,上面的 for 循环做的正是这件事。

Once we do that we want to find the width of that string given the font we're using, and for that we'll use font:getWidth. The maximum width of all our stats will be stored in the max_text_width variable and then we can start drawing our rectangle from there:

拿到这些文本之后,我们还要根据当前使用的字体去计算它们的宽度,这里会用到 font:getWidth。所有属性文字里最宽的那条会存进 max_text_width,然后我们就能据此去画矩形了:

lua
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

We want to draw the rectangle at the mouse position, except that now we don't have to use camera:getMousePosition because we're not drawing with the camera transformations. However, we can't simply use love.mouse.getPosition directly either because our canvas is being scaled by sx, sy, which means that the mouse position as returned by LÖVE's function isn't correct once we change the game's scale from 1. So we have to divide that position by the scale to get the appropriate value.

我们想把矩形画在鼠标位置附近,但这里已经不需要 camera:getMousePosition 了,因为现在这段绘制不受摄像机变换影响。不过也不能直接拿 love.mouse.getPosition 的结果来用,因为我们的 canvas 被 sx, sy 缩放过了,游戏缩放不为 1 时,LÖVE 返回的鼠标坐标就不再是我们真正要的值。所以这里必须先除以缩放值,才能得到正确坐标。

After we have the proper position we can draw the rectangle with width 16 + max_text_width, which gives us about 8 pixels on each side as a border, and then with height font:getHeight() + (#stats/3)*font:getHeight(). The first element of this calculation (font:getHeight()) serves the same purpose as 16 in the width calculation, which is to be just some value for a border. In this case the rectangle will have font:getHeight()/2 as a top and bottom border. The second part of is simply the amount of height each stat line takes. Since stats are grouped in threes, it makes sense to count each stat as #stats/3 and then multiply that by the line height.

坐标算对之后,就可以画矩形了。宽度用 16 + max_text_width,也就是左右各留大约 8 像素的边距。高度则用 font:getHeight() + (#stats/3)*font:getHeight()。这里前半部分的 font:getHeight(),作用和宽度里的 16 一样,都是作为边距;也就是说,上下边距各是 font:getHeight()/2。后半部分则是所有属性行本身占的总高度。因为属性每 3 个元素为一组,所以总行数就是 #stats/3,再乘上每行字体高度即可。

Finally, the last thing to do is to draw the text. We know that the x position of all texts will be 8 + mx, because we decided we wanted 8 pixels of border on each side. And we also know that the y position of the first text will be my + font:getHeight()/2, because we decided we want font:getHeight()/2 as border on top and bottom. The only thing left to figure out is how to draw multiple lines, but we also already know this since we decided that the height of the rectangle would be (#stats/3)*font:getHeight(). This means that each line is drawn 1*font:getHeight(), 2*font:getHeight(), and so on. All that looks like this:

最后一步就是把文字真正画出来。所有文字的 x 坐标都应该是 8 + mx,因为我们已经决定左右边距各留 8 像素。第一行文字的 y 坐标则是 my + font:getHeight()/2,因为上下边距是 font:getHeight()/2。剩下唯一要处理的,就是多行文字怎么逐行往下排;而这个我们其实也已经知道了,因为矩形高度本来就是按 (#stats/3)*font:getHeight() 来算的。所以每一行只要按 1*font:getHeight()2*font:getHeight() 这样往下叠就行。完整代码如下:

lua
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

And this should get us the result we want. As a small note on this, if you look at this code as a whole it looks like this:

这样应该就能得到我们想要的效果了。顺带说一句,如果把这段代码整体放在一起看,它大概是这样的:

lua
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

And I know that if I looked at code like this a few years ago I'd be really bothered by it. It looks ugly and unorganized and perhaps confusing, but in my experience this is the stereotypical game development drawing code. Lots of small and seemingly random numbers everywhere, pixel adjustments, lots of different concerns instead of the whole thing feeling cohesive, and so on. I'm very used to this type of code by now so it doesn't bother me anymore, and I'd advise you to get used to it too because trying to make it "cleaner", in my experience, only leads to things that are even more confusing and less intuitive to work with.

我知道,如果是几年前的我看到这种代码,肯定会非常难受。它看起来又丑、又乱、还可能有点绕。但按我的经验,这就是非常典型的游戏开发绘制代码风格。到处都是一些看起来随意的小数字、各种像素级微调、不同关注点混在一起,而不是那种结构特别整齐统一的感觉。现在我已经很习惯这种代码了,所以不会再被它困扰。我也建议你尽量习惯它,因为很多时候你越想把它“整理干净”,最后反而会变得更绕,也更不直观。

Gameplay

Now that we can place nodes and link them together we have to code in the logic behind buying nodes. The tree will have one or multiple "entry points" from which the player can start buying nodes, and then from there he can only buy nodes that adjacent to one he already bought. For instance, in the way I set my own tree up, there's a central starting node that provides no bonuses and then from it 4 additional ones connect out to start the tree:

现在节点能放、也能连了,接下来就得把“购买节点”的逻辑写出来。技能树会有一个或多个“入口节点”,玩家只能从这些入口开始点,然后只能继续购买那些与已购节点相邻的节点。比如我自己的树里,就是在正中间放了一个没有任何加成的起始节点,再从它向外连出 4 条分支,作为整棵树的开端:

Suppose now that we have a tree that looks like this initially:

假设现在我们有一棵初始状态如下的树:

lua
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}}

The first thing we wanna do is make it so that node #1 is already activated while the others are not. What I mean by a node being activated is that it has been bought by the player and so its effects will be applied in gameplay. Since node #1 has no effects, in this way we can create an "initial node" from where the tree will expand.

第一件要做的事,是让 1 号节点一开始就处于激活状态,而其他节点都还没激活。这里所谓“激活”,意思就是这个节点已经被玩家买下,它的效果会真正作用到游戏里。因为 1 号节点本身没有任何效果,所以它非常适合作为技能树展开的“初始节点”。

The way we'll do this is through a global table called bought_node_indexes, which will just contain a bunch of numbers pointing to which nodes of the tree have already been bought. In this case we can just add 1 to it, which means that tree[1] will be active. We also need to change the nodes and links visually a bit so we can more easily see which ones are active or not. For now we'll simply show locked nodes as grey (with alpha = 32 instead of 255) instead of white:

实现方式是用一张全局表,名字叫 bought_node_indexes,里面只存一些数字,用来表示技能树里的哪些节点已经买下了。这里我们只要往里面放一个 1,也就意味着 tree[1] 会被视为已激活。同时,我们还需要在视觉上区分哪些节点和连线已经激活、哪些还没激活。现在先用最简单的方式:未解锁节点显示成灰色,也就是 alpha 设为 32,而不是 255 的纯白。

lua
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

And for the links:

连线也是同样的思路:

lua
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

We only activate a line if both of its nodes have been bought, which makes sense. If we say that bought_node_indexes = {1} in the SkillTree room constructor, now we'd get something like this:

只有当一条线连接的两个节点都被买下时,这条线才会显示为激活状态,这个逻辑也很合理。如果我们在 SkillTree room 的构造函数里写 bought_node_indexes = {1},那么现在画面会是这样:

And if we say that bought_node_indexes = {1, 2}, then we'd get this:

如果写成 bought_node_indexes = {1, 2},效果就会变成这样:

And this is working as we expected. Now what we want to do is add the logic necessary so that whenever we click on a node it will be bought if its connected to another node that has been bought. Figuring out if we have enough skill points to buy a certain node, or to add a confirmation step before fully committing to buying the node will be left as an exercise.

这一部分已经按预期工作了。接下来要做的是:只要玩家点击某个节点,并且这个节点与某个已购节点相连,那么它就应该可以被买下。至于玩家是否有足够技能点,或者购买前是否还要加一个确认步骤,这些先留作练习。

Before we make it so that only nodes connected to other bought nodes can be bought, we first must fix a small problem with the way we're defining our tree. This is the definition we have now:

不过在写“只能购买与已购节点相连的节点”这套逻辑之前,我们得先修一下当前技能树定义方式里的一个小问题。现在的定义是这样的:

lua
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}}

One of the problems with this definition is that it's unidirectional. And this is a reasonable thing to expect, since if it were unidirectional we'd have to define connections multiple times across multiple nodes like this:

它的一个问题是,连线关系是单向的。其实这也不奇怪,因为如果要求双向手写,那我们就得在多个节点里重复定义同一条连接,比如这样:

lua
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}}}

And while there's no big problem in having to do this, we can make it so that we only have to define connections once (in either direction) and then we can apply an operation that will automatically make connections also be defined in the opposing direction.

虽然这么写也不是不行,但其实我们可以更省事一点,让每条连接只需要定义一次,无论你写的是哪个方向都行。之后再跑一遍处理,把反方向的连接自动补上。

The way we can do this is by going over the list of all nodes, and then for each node going over its links. For each link we find, we go over to that node and add the current node to its links. So, for instance, if we're on node 1 and we see that it's linked to 2, then we move over to node 2 and add 1 to its links list. In this way we'll make sure that whenever we have a definition going one way it will also go the other. In code this looks like this:

做法就是遍历所有节点,再遍历每个节点的 links。每发现一条连接,就跳到对应节点,把当前节点的 id 也加进对方的 links 列表。比如我们在节点 1 上看到它连向 2,就去节点 2 的 links 里补上一个 1。这样一来,凡是单向定义过的连接,都会自动补成双向。代码如下:

lua
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

The first thing to notice here is that instead of using the global tree variable now, we're copying it locally to the self.tree attribute and then using that attribute instead. Everywhere on the SkillTree, Node and Line objects we should change references to the global tree to the local SkillTree tree attribute instead. We need to do this because we're going to change the tree's definition by adding numbers to the links table of some nodes, and generally (because of what I outlined in article 10) we don't want to be changing global variables in that way. This means that every time we enter the SkillTree room, we'll copy the global definition over to a local one and use the local one instead.

这里首先要注意的是,我们不再直接使用全局 tree 变量,而是先把它复制到本地的 self.tree 属性里,然后后续都操作这个本地版本。也就是说,SkillTree、Node、Line 这些对象里,凡是原来引用全局 tree 的地方,都应该改成引用 SkillTree 的本地树。原因是我们接下来会往某些节点的 links 里插数字,也就是会改动树的定义;而按照第 10 篇里说过的原则,一般不希望这样直接去改全局变量。所以每次进入 SkillTree room 时,都复制一份全局定义过来,在本地上动手。

Given this, we now go over all nodes in the tree and back-link nodes to each other like we said we would. It's important to use node.links or {} inside the ipairs call because some nodes might not have their links table defined. It's also important to note that we do this before creating Node and Line objects, even though it's not really necessary to do that.

在这个前提下,我们就可以像刚才说的那样,遍历整棵树并把反向连接补齐。这里在 ipairs 里写 node.links or {} 很重要,因为有些节点可能根本没定义 links。另外还要注意,这一步是在创建 Node 和 Line 对象之前做的,虽然严格来说不一定非得这样,但这么排会更顺手。

An additional thing we can do here is to note that sometimes a links table will have repeated values. Depending on how we define the tree table sometimes we'll place nodes bi-directionally, which means that they'll already be everywhere they should be. This isn't really a problem, except that it might result in the creation of multiple Line objects. So to prevent that, we can go over the tree again and make it so that all links tables only contain unique values:

这里还可以顺便再处理一个问题:有时某个 links 表里可能会出现重复值。因为根据你怎么写 tree,有些连接本来就可能已经双向定义好了。重复本身问题不大,但它可能导致我们生成重复的 Line 对象。所以可以再遍历一遍树,把所有 links 表都去重:

lua
function SkillTree:new()
    ...
    for id, node in ipairs(self.tree) do
        if node.links then
            node.links = fn.unique(node.links)
        end
    end
    ...
end

Now the only thing left to do is making it so that whenever we click a node, we check to see if its linked to an already bought node:

现在剩下的,就是在点击某个节点时,检查它是不是和某个已购节点相连:

lua
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

And so this means that if a node is being hovered over and the player presses the left click button, we'll check to see if this node can be bought through SkillTree's canNodeBeBought function (which we still have to implement), and then if it can be bought we'll add it to the global bought_node_indexes table. Here we also take care to not add a node twice to that table. Although if we add it more than once it won't really change anything or cause any bugs.

也就是说,只要鼠标正悬停在节点上,并且玩家按下了左键,我们就通过 SkillTree 的 canNodeBeBought 函数来判断这个节点是否可以购买。这个函数还没实现,不过马上就会补上。如果它可买,就把这个节点的 id 加进全局 bought_node_indexes 表中。这里顺手也避免了重复添加同一个节点,虽然就算重复加了,实际上也未必会出 bug。

The canNodeBeBought function will work by going over the linked nodes to the node that was passed in and seeing if any of them are inside the bought_node_indexes table. If that's true then it means this node is connected to an already bought node which means that it can be bought:

canNodeBeBought 的逻辑很简单:遍历传进来的这个节点所连接的所有节点,看看其中有没有任意一个已经存在于 bought_node_indexes 表里。如果有,就说明当前节点与某个已购节点相连,因此可以购买:

lua
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

And this should work as expected:

这样应该就能按预期工作了:

The very last idea we'll go over is how to apply our selected nodes to the player. This is simpler than it seems because of how we decided to structure everything in articles 11 and 12. The tree definition looks like this now:

最后再讲一个关键点:怎么把玩家在技能树里选中的节点真正应用到玩家对象上。因为我们在第 11、12 篇里已经把整体结构铺好了,所以这件事其实比看上去简单。现在树的定义大概是这样:

lua
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}}

And if you notice, we have the second stat value being a string that should point to a variable defined in the Player object. In this case the variable is hp_multiplier. If we go back to the Player object and look for where hp_multiplier is used we'll find this:

你会发现,每条属性里的第二个值都是一个字符串,它对应的是 Player 对象里的某个变量。这里的例子是 hp_multiplier。如果回到 Player 对象里去找这个变量被使用的地方,会看到这样的代码:

lua
function Player:setStats()
    self.max_hp = (self.max_hp + self.flat_hp)*self.hp_multiplier
    self.hp = self.max_hp
    ...
end

It's used in the setStats function as a multiplier for our base HP added by some flat HP value, which is what we expected. The behavior we want out of the tree is that for all nodes inside bought_node_indexes, we'll apply their stat to the appropriate player variable. So if we have nodes 2, 3 and 4 inside that table, then the player should have an hp_multiplier that is equal to 1.14 (0.04+0.06+0.04 + the base which is 1). We can do this fairly simply like this:

可以看到,它在 setStats 函数里被用来给基础 HP 加上 flat HP 之后再乘倍率,这正是我们预期中的用法。而技能树想要实现的效果就是:对于 bought_node_indexes 里的所有节点,把它们的属性值加到玩家对应的变量上。比如如果 2、3、4 号节点都在这张表里,那么玩家的 hp_multiplier 最终就应该是 1.14,也就是 0.04 + 0.06 + 0.04 + 基础值 1。这件事写起来其实很直接:

lua
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

We define this function in tree.lua. As expected, we're going over all bought nodes and then going over all their stats. For each stat we're taking the attribute ('hp_multiplier') and the value (0.04, 0.06) and applying it to the player. In the example we talked the player[attribute] = player[attribute] + value line is parsed to player.hp_multiplier = player.hp_multiplier + 0.04 or player.hp_multiplier = player.hp_multiplier + 0.06, depending on which node we're currently looping over. This means that by the end of the outer for, we'll have applied all passives we bought to the player's variables.

这个函数定义在 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 跑完之后,玩家点出来的所有被动就都已经加到对应变量上了。

It's important to note that different passives will need to be handled slightly differently. Some passives are booleans, others should be applied to variables which are Stat objects, and so on. All those differences need to be handled inside this function.

要注意的是,不同类型的被动在处理时会有一些差别。有些是布尔值,有些应该加到 Stat 对象上,还有别的特殊情况。这些差异最终都需要在这个函数里分开处理。

224. (CONTENT) Implement skill points. We have a global skill_points variable which holds how many skill points the player has. This variable should be decreased by 1 whenever the player buys a new node in the skill tree. The player should not be allowed to buy more nodes if he has no skill points. The player can buy a maximum of 100 nodes. You may also want to change these numbers around a bit if you feel like it's necessary. For instance, in my game the cost of each node increases based on how many nodes the player has already bought.

224. (CONTENT) 实现 skill points。我们有一个全局变量 skill_points,表示玩家当前拥有多少技能点。玩家每买下一个新节点,这个值就应该减 1;如果没有技能点,就不允许继续购买。玩家最多只能买 100 个节点。当然,如果你觉得有必要,也可以自己调整这些数值。比如在我的游戏里,每个节点的花费会随着已购节点数量增加而提高。

225. (CONTENT) Implement a step before buying nodes where the player can cancel his choices. This means that the player can click on nodes as if they were being bought, but to confirm the purchase he has to hit the "Apply Points" button. All selected nodes can be cancelled if he clicks the "Cancel" button instead. This is what it looks like:

225. (CONTENT) 在真正购买节点之前,加一个允许玩家反悔的步骤。也就是说,玩家点节点时可以先当作“已选中待购买”,但要真正生效,还得再点一次 Apply Points 按钮确认。如果点的是 Cancel 按钮,那么这次选中的所有节点都要被撤销。效果如下:

226. (CONTENT) Implement the skill tree. You can implement this skill tree to whatever size you see fit, but obviously the bigger it is the more possible interactions there will be and the more interesting it will be as well. This is what my tree looks like for reference:

226. (CONTENT) 把整棵技能树真正做出来。你可以按自己觉得合适的规模去实现,但显然树越大,可能出现的组合和交互就越多,也会越有意思。下面是我自己的树,给你作个参考:

Don't forget to add the appropriate behaviors for each different type of passive in the treeToPlayer function!

别忘了在 treeToPlayer 函数里,为不同类型的被动补上各自对应的处理逻辑。

END

And with that we end this article. The next article will focus on the Console room and the one after that will be the final one. In the final article we'll go over a few things, one of them being saving and loading things. One aspect of the skill tree that we didn't talk about was saving the player's bought nodes. We want those nodes to remain bought through playthroughs as well as after the player closes the game, so in the final article we'll go over this in more detail.

到这里,这一篇就结束了。下一篇会讲 Console room,再下一篇就是最后一篇。在最终篇里,我们会把几个收尾问题补上,其中一个就是存档和读取。关于技能树,还有一个这篇没展开说的点,就是如何保存玩家已经购买过的节点。我们希望这些节点不仅在一局接一局之间保留下来,也要在玩家退出游戏后继续保存,所以这个问题会放到最后一篇里详细讲。

And like I said multiple times before, if you don't feel like it you don't need to implement a skill tree. If you've followed along so far then you already have all the passives implemented from articles 11 and 12 and you can present them to the player in whatever way you see fit. I chose a tree, but you can choose something else if you don't feel like doing a big tree like this manually is a good idea.

而且就像我前面反复说过的,如果你自己不想做技能树,也完全没必要硬上。只要你一路跟到了这里,那么第 11 和第 12 篇里的所有被动其实已经都实现完了,你完全可以用任何你喜欢的方式把它们呈现给玩家。我选的是技能树,但如果你觉得手工做这么大一棵树不是个好主意,也完全可以换成别的方案。