天赋树系统笔记 这个天赋树本质上不是一棵严格的“树”,而是一个配置驱动的图结构系统 。
玩家看到的是一张星盘式天赋图:
1 2 3 4 5 6 节点 = 一个天赋 线 = 两个天赋之间的连接关系 已点亮节点 = 已学习天赋 灰色节点 = 未学习天赋 彩色线 = 已连接路径 半透明/呼吸线 = 当前可继续学习的方向
系统整体可以分成五层:
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 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 4 5 天赋名 描述 属性效果 需要消耗几点 学习按钮 / 重置按钮
6. 连线生成:节点关系变成线 系统会根据邻接关系生成连线。
由于节点关系是无向图,A-B 和 B-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 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);
圆弧线则需要:
然后用自定义 UI Graphic 绘制弧线。
7. 连线状态:灰、亮、可连接 连线不是每次刷新都重新创建。
更好的做法是:
线有三种状态:
判断逻辑:
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
系统会自动计算:
如果点数够,就一次性提交。
因为每个节点消耗可能不同,所以不能简单用 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:
C 和 D 虽然原来学过,但它们已经无法从起点连通了,所以也应该被连带重置。
这个判断用 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 两者解决的问题不同。
学习时关心成本:
所以用 Dijkstra。
重置时不关心成本:
所以用 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 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. 最后再做圆弧、特效、缩放、镜头
最小版本只需要:
不要一开始就做圆弧和特效。
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 计算重置断连, 再把图状态映射成节点和连线表现。
只要理解了这一句,后面所有脚本都能归位。