dark_天赋树分析

天赋树系统笔记

这个天赋树本质上不是一棵严格的“树”,而是一个配置驱动的图结构系统

玩家看到的是一张星盘式天赋图:

1
2
3
4
5
6
节点 = 一个天赋
线 = 两个天赋之间的连接关系
已点亮节点 = 已学习天赋
灰色节点 = 未学习天赋
彩色线 = 已连接路径
半透明/呼吸线 = 当前可继续学习的方向

系统整体可以分成五层:

1
2
3
4
5
配置层
状态层
算法层
协议层
表现层

1. 配置层:天赋树不是写死的

每个节点的基础信息都来自配置表。

一条节点配置大概包含:

1
2
3
4
5
6
7
8
9
10
11
id                  节点ID
type 节点类型:小型、核心、诅咒/特殊
name 名字
desc 描述
posiX, posiY 节点在设计画布上的坐标
attribute 对应属性或效果
iconName 图标
isStart 是否起始节点
needTalentPoint 学习消耗
adjacentNodeList 邻接节点列表
adjacentNodeLineList 邻接节点 + 线型

例如:

1
2
3
4
5
6
7
8
9
10
{
"id": 1,
"type": 1,
"posiX": 350,
"posiY": 1474,
"isStart": 0,
"needTalentPoint": 1,
"adjacentNodeList": "6|2|328",
"adjacentNodeLineList": "6-c16|2-c16|328-c0"
}

这里有两个容易混淆的字段:

1
2
3
4
5
adjacentNodeList
用于图算法,表示这个节点和哪些节点相邻。

adjacentNodeLineList
用于画线,除了相邻节点,还带了线型,比如 c0 / c16。

c0 一般表示直线。

c16 这种表示圆弧线,需要去另一张圆弧配置表里找圆心和半径。

圆弧配置大概是:

1
2
3
4
5
6
{
"arcId": "c16",
"centerX": 261,
"centerY": 1472,
"radius": 89
}

这说明这个系统的布局不是运行时自动排版,而是设计阶段已经把节点坐标和圆弧数据配置好了。运行时只是读取并生成 UI。

2. 坐标是怎么来的

节点表里的 posiX / posiY 是设计坐标,不是最终 Unity UI 坐标。

系统会做一次坐标变换:

1
2
3
4
Vector2 GetNodePosition(float x, float y)
{
return new Vector2(x - pivot.x, y - pivot.y) * scale + offset;
}

可以理解成:

1
最终UI坐标 = (配置坐标 - 原点修正) * 缩放 + 整体偏移

这种做法的好处是:

1
2
3
配置表可以使用一套很大的设计画布坐标
Unity UI 里可以整体缩放、整体平移
后续调整整张天赋图位置时,不需要改每个节点

一般这种坐标有几种来源:

1
2
3
4
1. 策划/美术用可视化编辑器拖出来
2. Unity Editor 工具摆节点后导表
3. Figma/PS/AI 设计稿转坐标
4. 圆环节点用公式生成,再人工调整

圆形节点常用公式:

1
2
3
float angle = index * Mathf.PI * 2f / count;
float x = centerX + Mathf.Cos(angle) * radius;
float y = centerY + Mathf.Sin(angle) * radius;

很多星盘天赋树就是这样:

1
2
大结构靠人工设计
小环结构靠圆心、半径、角度生成

3. 状态层:玩家当前学了什么

系统会维护一份玩家天赋状态:

1
2
3
4
5
6
7
class TalentState
{
int remainingPoints;
HashSet<int> learnedNodeIds;
List<int> startNodeIds;
int lastLearnedNodeId;
}

核心状态有几个:

1
2
3
4
5
6
7
8
9
10
11
remainingPoints
剩余天赋点。

learnedNodeIds
已经学习过的节点。

startNodeIds
所有起始节点。

lastLearnedNodeId
最后一次学习的节点,用于打开界面时镜头定位。

这里要注意:

1
2
配置表描述的是“天赋树长什么样”
玩家状态描述的是“当前点亮了哪些节点”

这两个东西要分开。

4. 协议层:客户端预判,服务端确认

客户端可以做学习路径预判,也可以先算重置影响,但最终状态一般由服务端确认。

查询天赋数据:

1
2
3
{
"cmd": 4050
}

服务端返回:

1
2
3
4
{
"list": [1, 2, 3],
"point": 12
}

含义:

1
2
list = 已学习节点
point = 剩余天赋点

更新天赋:

1
2
3
4
5
{
"cmd": 4051,
"type": 1,
"list": [4, 5, 6]
}

type 通常表示:

1
2
3
0 = 重置全部
1 = 学习节点
2 = 重置节点

客户端的原则是:

1
2
3
点击时先本地计算能不能做
请求服务端
服务端返回后再刷新最终状态

这样体验比较好,同时也能避免客户端状态乱掉。

5. 节点生成:配置变成 UI

运行时会遍历所有节点配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
foreach (var config in talentTreeConfigs)
{
TalentNodeData data = new TalentNodeData();

data.id = config.id;
data.type = config.type;
data.cost = config.needTalentPoint;
data.position = GetNodePosition(config.posiX, config.posiY);
data.neighbors = ParseNeighbors(config.adjacentNodeList);

GameObject prefab = GetPrefabByType(data.type);
GameObject nodeObj = Instantiate(prefab, content);

nodeObj.RectTransform.anchoredPosition = data.position;
nodeObj.Init(data);
}

节点 prefab 根据类型不同而不同:

1
2
3
小型天赋节点
核心天赋节点
诅咒/特殊天赋节点

节点点击后不会直接学习,而是先打开详情面板。

详情面板显示:

1
2
3
4
5
天赋名
描述
属性效果
需要消耗几点
学习按钮 / 重置按钮

6. 连线生成:节点关系变成线

系统会根据邻接关系生成连线。

由于节点关系是无向图,A-BB-A 其实是同一条线,所以需要去重。

常见做法:

1
2
3
4
5
6
string GetLineKey(int a, int b)
{
int min = Mathf.Min(a, b);
int max = Mathf.Max(a, b);
return $"{min}_{max}";
}

生成连线时:

1
2
3
4
5
6
7
8
9
10
11
12
foreach (var node in nodes)
{
foreach (var neighbor in node.neighbors)
{
string key = GetLineKey(node.id, neighbor.id);

if (lineDict.ContainsKey(key))
continue;

CreateLine(node, neighbor);
}
}

线有两种:

1
2
直线
圆弧线

直线比较简单:

1
2
3
4
5
6
7
8
9
10
Vector2 start = startNode.position;
Vector2 end = endNode.position;

Vector2 middle = (start + end) / 2;
float distance = Vector2.Distance(start, end);
float angle = Mathf.Atan2(end.y - start.y, end.x - start.x) * Mathf.Rad2Deg;

line.anchoredPosition = middle;
line.sizeDelta = new Vector2(distance, width);
line.rotation = Quaternion.Euler(0, 0, angle);

圆弧线则需要:

1
2
3
4
5
圆心
半径
起点
终点
线宽

然后用自定义 UI Graphic 绘制弧线。

7. 连线状态:灰、亮、可连接

连线不是每次刷新都重新创建。

更好的做法是:

1
2
初始化时创建所有线
刷新时只改状态

线有三种状态:

1
2
3
0 = 未连接
1 = 已连接
2 = 可连接

判断逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
if (两个端点都已学习)
{
line.state = Connected;
}
else if (一个端点已学习,另一个未学习,并且还有天赋点)
{
line.state = CanConnect;
}
else
{
line.state = Normal;
}

表现上:

1
2
3
未连接:灰色
已连接:彩色
可连接:半透明彩色 / 呼吸动画

这个设计很好,因为玩家可以直观看到:

1
2
3
我已经走过哪里
我下一步可以往哪里扩展
哪些地方还到不了

8. 学习节点:核心是 Dijkstra

这是这个系统最重要的算法之一。

玩家点击一个未学习节点时,系统不只是判断“它旁边有没有已学节点”。

它支持玩家直接点击远处节点,然后自动补齐中间路径。

比如:

1
2
3
4
5
A -- B -- C -- D

A 已学习
B/C/D 未学习
玩家点击 D

系统会自动计算:

1
需要学习 B、C、D

如果点数够,就一次性提交。

因为每个节点消耗可能不同,所以不能简单用 BFS 找最短步数,而是用 Dijkstra 找最低成本路径。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
List<int> FindLearnPath(int targetId, int availablePoints)
{
if (learned.Contains(targetId))
return null;

if (IsStartNode(targetId))
{
return Cost(targetId) <= availablePoints
? new List<int> { targetId }
: null;
}

PriorityQueue<NodeCost> open = new();
Dictionary<int, int> bestCost = new();
Dictionary<int, int> previous = new();

open.Push(targetId, Cost(targetId));
bestCost[targetId] = Cost(targetId);
previous[targetId] = -1;

while (open.NotEmpty)
{
var current = open.PopMinCost();

if (learned.Contains(current.id) || IsStartNode(current.id))
{
return BuildPath(previous, current.id, targetId);
}

foreach (int neighbor in GetNeighbors(current.id))
{
int addCost = learned.Contains(neighbor)
? 0
: Cost(neighbor);

int newCost = current.cost + addCost;

if (newCost > availablePoints)
continue;

if (!bestCost.ContainsKey(neighbor) || newCost < bestCost[neighbor])
{
bestCost[neighbor] = newCost;
previous[neighbor] = current.id;
open.Push(neighbor, newCost);
}
}
}

return null;
}

重点思想:

1
2
3
4
从目标节点反向找
一直找到已学习节点或起始节点
过程中累计未学习节点的消耗
最后回溯出需要补学的路径

为什么从目标反向找?

1
2
3
因为玩家点击的是一个明确目标
从目标往回找最近的已学网络
可以得到“为了学这个点,最低成本需要补哪些点”

9. 显示消耗:复用同一套路径算法

详情面板显示“学习该节点需要几点”时,也可以调用同一套路径搜索。

区别是:

1
2
真正学习时:点数不够就失败
显示消耗时:可以先不限制点数,算理论最低成本

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LearnCostResult CalcLearnCost(int nodeId)
{
if (learned.Contains(nodeId))
return new LearnCostResult(0, true);

List<int> path = FindLearnPath(nodeId, int.MaxValue);

if (path == null)
return new LearnCostResult(-1, false);

int cost = Sum(path.Select(node => node.cost));

return new LearnCostResult(
needPoints: cost,
canAfford: remainingPoints >= cost
);
}

这个设计的好处是:

1
2
UI显示和真实学习规则一致
不会出现显示要3点,实际点击却要4点的问题

10. 重置节点:核心是 BFS 连通性检测

重置一个节点时,可能会导致后面一串节点断开。

例如:

1
2
3
4
A -- B -- C -- D

A 是起始节点
A/B/C/D 都已学习

如果重置 B

1
A    C -- D

CD 虽然原来学过,但它们已经无法从起点连通了,所以也应该被连带重置。

这个判断用 BFS。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
List<int> CalcRemoveImpact(int removeId)
{
HashSet<int> reachable = new();
Queue<int> queue = new();

foreach (int startId in startNodeIds)
{
if (startId == removeId)
continue;

if (!learned.Contains(startId))
continue;

reachable.Add(startId);
queue.Enqueue(startId);
}

while (queue.Count > 0)
{
int current = queue.Dequeue();

foreach (int neighbor in GetNeighbors(current))
{
if (neighbor == removeId)
continue;

if (!learned.Contains(neighbor))
continue;

if (reachable.Contains(neighbor))
continue;

reachable.Add(neighbor);
queue.Enqueue(neighbor);
}
}

List<int> affected = new();

foreach (int learnedId in learned)
{
if (learnedId == removeId)
continue;

if (!reachable.Contains(learnedId))
affected.Add(learnedId);
}

return affected;
}

这段算法回答的是:

1
2
3
假设移除这个节点
从所有已学习的起点出发
还可以走到哪些已学习节点?

走不到的,就是受影响节点。

11. 为什么学习用 Dijkstra,重置用 BFS

两者解决的问题不同。

学习时关心成本:

1
我要学到目标节点,哪条路径消耗最少?

所以用 Dijkstra。

重置时不关心成本:

1
移除某节点后,哪些节点还连得上起点?

所以用 BFS 就够了。

总结:

1
2
Dijkstra:有权重,找最低成本路径
BFS:无权重,找可达节点

这个算法选择很合理。

12. 事件刷新:状态变了,UI 自己响应

当服务器返回新的已学列表和剩余点数后,系统会触发一个统一刷新事件。

大概是:

1
2
3
4
OnTalentDataChanged()
{
RefreshTalentTreeEvent.Invoke();
}

然后不同 UI 模块自己监听:

1
2
3
4
5
节点模块:刷新节点高亮/置灰
连线模块:刷新线状态
顶部模块:刷新剩余点数
详情面板:必要时关闭或刷新
红点模块:刷新是否有可用天赋点

这种设计比互相直接调用更清晰。

数据变化流程:

1
2
3
4
5
6
服务器返回
-> 更新本地状态
-> 发刷新事件
-> 节点刷新
-> 连线刷新
-> 顶部点数刷新

13. 整个学习流程

玩家学习一个节点,大概是:

1
2
3
4
5
6
7
8
9
10
11
点击节点
-> 打开详情面板
-> 显示名称、描述、消耗
-> 点击学习
-> 用 Dijkstra 计算补学路径
-> 判断点数是否足够
-> 发送学习请求
-> 服务端返回新状态
-> 更新已学节点和剩余点数
-> 刷新节点和连线
-> 播放首次点亮特效

伪流程:

1
2
3
4
5
6
7
8
9
10
11
12
void OnClickLearn(int nodeId)
{
List<int> path = FindLearnPath(nodeId, remainingPoints);

if (path == null)
{
ShowTip("天赋点不足或不可达");
return;
}

SendLearnRequest(path);
}

14. 整个重置流程

玩家重置一个节点,大概是:

1
2
3
4
5
6
7
8
9
10
点击已学习节点
-> 打开详情面板
-> 点击重置
-> BFS 计算受影响节点
-> 如果没有影响,直接请求重置
-> 如果有影响,弹确认框
-> 确认后发送重置请求
-> 服务端返回新状态
-> 刷新节点和连线
-> 播放重置特效

伪流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void OnClickReset(int nodeId)
{
List<int> affected = CalcRemoveImpact(nodeId);

if (affected.Count > 0)
{
ShowConfirm($"会连带重置 {affected.Count} 个节点", () =>
{
affected.Add(nodeId);
SendResetRequest(affected);
});
}
else
{
SendResetRequest(new List<int> { nodeId });
}
}

15. 用到的技术点

这个系统主要用到了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
UGUI
TextMeshPro
配置表驱动
JSON配置读取
Socket协议通信
事件系统
Dijkstra最短路径
BFS连通性检测
自定义UI绘制圆弧
RectTransform坐标变换
异步资源加载
红点系统
多语言系统
简单对象缓存/状态刷新优化

技术上最值得学的是:

1
2
3
4
5
6
把复杂 UI 模块拆成:
配置
状态
算法
网络
表现

而不是把所有逻辑都写在按钮点击里。

16. 这个系统可取的地方

16.1 天赋树被建模成图,而不是树

这很重要。

如果用树结构,只能表达:

1
父节点 -> 子节点

但实际星盘天赋常常有:

1
2
3
4
5
6
多个起点
多个路径
路径汇合
环形结构
交叉连接
特殊节点

用图结构更自然。

16.2 客户端能预判路径和影响

玩家点击远处节点时,系统能自动算补学路径。

玩家重置节点时,系统能提前告诉你会影响多少节点。

这让操作更顺滑。

16.3 表现和逻辑分离

1
2
3
配置决定节点关系
算法决定能不能学、怎么重置
UI 只负责显示结果

16.4 连线初始化一次,刷新只改状态

这比每次刷新销毁重建所有线更好。

16.5 坐标配置化

复杂星盘不适合运行时自动排版,坐标配置化让视觉更可控。

17. 如果自己复刻,可以按这个顺序做

第一版不要做太复杂。

建议顺序:

1
2
3
4
5
6
7
8
9
10
11
1. 先做节点配置表
2. 读取配置生成节点
3. 根据邻接关系画直线
4. 做已学习/未学习状态
5. 做起始节点学习
6. 做 Dijkstra 自动补路径
7. 做 BFS 重置影响
8. 做连线状态刷新
9. 做详情面板
10. 做服务器同步
11. 最后再做圆弧、特效、缩放、镜头

最小版本只需要:

1
2
3
4
5
节点
直线
学习
重置
点数

不要一开始就做圆弧和特效。

18. 复刻时建议的模块设计

可以拆成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class TalentConfigLoader
{
Dictionary<int, TalentNodeConfig> LoadNodes();
Dictionary<string, ArcConfig> LoadArcs();
}

class TalentState
{
int remainingPoints;
HashSet<int> learned;
}

class TalentGraphService
{
List<int> FindLearnPath(int targetId);
List<int> CalcRemoveImpact(int removeId);
List<Edge> GetConnectedEdges();
List<Edge> GetLearnableEdges();
}

class TalentNetworkService
{
void RequestTalentInfo();
void RequestLearn(List<int> nodeIds);
void RequestReset(List<int> nodeIds);
}

class TalentTreeView
{
void BuildNodes();
void BuildLines();
void Refresh();
}

这里最关键的是 TalentGraphService

算法最好做成纯逻辑,不要依赖 UI 对象。

不要这样:

1
if (button.gameObject.activeSelf)

而是这样:

1
if (state.learned.Contains(nodeId))

这样以后好测试,也好复用。

19. 一个简化版核心数据结构

可以这样设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class TalentNode
{
public int id;
public int type;
public int cost;
public bool isStart;
public Vector2 position;
public List<int> neighbors = new();
}

public class TalentGraph
{
public Dictionary<int, TalentNode> nodes = new();
}

public class PlayerTalentState
{
public int points;
public HashSet<int> learned = new();
}

20. 最核心的一句话

这个天赋树系统的核心不是 UI,而是:

1
2
3
4
5
用配置表描述一张图,
用玩家状态标记哪些节点已学习,
用 Dijkstra 计算学习路径,
用 BFS 计算重置断连,
再把图状态映射成节点和连线表现。

只要理解了这一句,后面所有脚本都能归位。