介绍
红点系统是目前绝大部分游戏都会拥有的功能,作用是在当玩家达成某种状态时(例如:任务完成,有新的邮件等),会在相关按钮上显示一个小红点来提示玩家进行相关操作。
如下图:
红点系统是一个看起来很简单,但是却很蛋疼的功能。麻烦的原因并不是实现起来很复杂,而是它是一个层层套娃的结构,并且涉及到游戏中的绝大部分模块,如果红点系统设计的不合理会导致加红点功能时会特别的麻烦,维护也特别困难,也很容易出Bug。(一般项目组的同事都特别讨厌加红点,2333…)
红点系统结构分析
红点系统其实是一个标准的树形结构,其红点点亮的方式是从其叶子节点开始往上点亮,直到主界面的红点被点亮
如下图,当K需要点亮红点时,会一直往上点亮,最后A节点被点亮( K – H – D – A )
红点系统设计
红点类结构(RedDotNode)
我们在登录游戏后会看到界面上会显示红点,这是我们并没有打开过其他界面,并且我们关闭了界面后,主界面的红点依旧会进行刷新。红点刷新与界面打开并没有直接的关联,也就是说红点的显示是由红点数据进行驱动的。为了保存红点数据我们需要一个结构进行红点信息存储,每一个红点都是一个对象。
在红点中我们需要存储哪些数据?
- name:节点点名字,在我们查找结点时会通过结点名称进行判断是否是我们要找的结点
- parent:父节点,在叶子节点状态刷新时,会通过父结点一直往上刷新(整个红点树的根节点是没有父节点的)
- childList:子节点列表,节点可能存在多个子节点,所以用一个列表存储该节点的所有子节点(叶子节点的childList是为空的)
- gameObject:红点游戏物体,界面中显示的红点,当节点有被注册时,会设置该gameObeject,并刷新显示。注销时置空
- count:红点的数量,这里存储的时数量而不是显示状态是因为有的红点会进行数量的显示,如果子节点有两个是点亮状态,则该节点在界面会显示数量2
构建红点树
构建树的方式:通过解析key来构建,key的格式:节点之间用.分隔
以图二为例:
- A的Key:a
- D的Key:a.d
- H的Key:a.d.h
- K的Key:a.d.h.k
这样只要将key以.进行分割,会得到一个节点名称列表,通过这个列表就可能很方便的构建对应的树结构,并且能准确的查找的对应的节点
什么时候构建
看过很多的红点系统是在游戏初始化的时候就会将整个红点树构建出来,这种方式对于固定结构(key)的红点是可以提前构建出来的(例如:主界面邮件按钮,它的位置是固定不变的且是常驻按钮),但是有的红点是没办法确定key值的,例如活动列表,这个列表是会动态变化的,活动的位置也可能是不一样的,这样就不太好提前构建红点树。所以这里我们采用的方式是在调用获取节点的方法(_GetNode)的时候,如果该节点不存在就会立即在红点树中构建对应的结构。
假如上面的K节点下面是一个活动列表,且活动是动态更新的,那么我们注册红点时的就可以使用下面的方式动态拼接key
- 签到活动id为1001,key:a.d.h.k.1001
- 充值活动id为1002,key:a.d.h.k.1002
下面是注册红点时的流程简图,图中绿色的步骤是在设置红点状态(SetValid)也会执行的
这种方式需要注意的是:一定要确保Key的结构正确,不能出现错误(如:父节点名字错误),这样会从出错的节点开始构建一个子树,会脱离正确结构
代码实现
这里使用的Lua语言实现的(因为目前正在开发的项目就是用的Lua语言)
红点节点脚本
local RedDotNode = class() -- class是项目中封装的一个模拟面向对象中的类的功能
function RedDotNode:__new(name, parent)
self.name = name -- 名称
self.parent = parent -- 父节点
self.childList = nil -- 子节点列表
self.gameObject = nil -- 红点游戏物体
self.txtComp = nil -- 显示数量的组件
self.count = 0 -- 红点数量
end
-- 注册红点时调用
function RedDotNode:RegisterHandle(parentGo)
-- 查找物体下面是否有红点物体
local go = __FindChild(parentGo, "RedDot")
-- 如果有直接设置为该节点的红点游戏物体
-- 否则就进行动态加载并实例化
if go then
self.gameObject = go
else
-- 这里是根据key的后缀加载不同的红点预制
-- 并设置其位置
local prefabName = string.endWith(self.name, "_NUM") and "NumRedDot" or "RedDot"
go = __UIResouseIns:LoadUIPrefab('UI/Panel/' .. prefabName, 0, parentGo.transform) -- 这个是我们项目封装的加载并实例化的接口
go.name = "RedDot"
local rtPoint = go:GetComponent(CSType.RectTransform)
if not rtPoint then
rtPoint = go:AddComponent(CSType.RectTransform)
end
if not rtPoint then
UnityEngine.GameObject.Destroy(go)
return
end
rtPoint.anchorMax = Vector2.New(1, 1)
rtPoint.anchorMin = Vector2.New(1, 1)
rtPoint.anchoredPosition = Vector2.New(-16, -16)
self.gameObject = go
end
-- 查找红点下的显示数量的Text组件
self.txtComp = __FindChild(self.gameObject, "TxtCount", CSType.Text)
self:Refresh()
end
-- 注销红点时进行调用
function RedDotNode:UnRegisterHandle()
self.gameObject = nil
self.txtComp = nil
end
-- 红点是否点亮(当数量>0是该红点就为点亮状态)
function RedDotNode:IsValid()
return self.count > 0
end
-- 添加子节点
function RedDotNode:AddChild(name)
if not self.childList then
self.childList = {}
end
local node = RedDotNode.new(name, self)
table.append(self.childList, node)
return node
end
-- 通过名字获取子节点
function RedDotNode:GetChild(name)
for _, node in ipairs(self.childList or {}) do
if node.name == name then
return node
end
end
end
-- 设置红点显示状态
function RedDotNode:SetValid(isValid)
-- 如果设置的红点显示状态和当前状态一直,并且当前结点是叶子结点就不进行处理
if self:IsValid() == isValid and (not self.childList or #self.childList <= 0) then
return
end
if isValid then
-- 状态为 true, 红点数量加1
self.count = self.count + 1
else
-- 状态为 false,红点数量减1
self.count = self.count - 1
end
-- 刷新红点显示
self:Refresh()
-- 如果当前节点存在父节点,则继续设置父节点状态
if self.parent then
self.parent:SetValid(isValid)
end
end
-- 刷新红点显示
function RedDotNode:Refresh()
-- 如果红点游戏物体为null,不做处理
if tolua.isnull(self.gameObject) then
return
end
-- 如果存在显示红点数量的Text,就设置数量显示
if self.txtComp then
self.txtComp.text = self.count >= 100 and "99" or self.count
end
-- 设置红点物体的显示状态
__SetActive(self.gameObject, self:IsValid())
end
return RedDotNode
红点管理器
RedDotMgr = {}
require("scripts/common/reddot/RedDotDefine")
local RedDotNode = require("scripts/common/reddot/RedDotNode")
RedDotMgr.key_map = {}
RedDotMgr.root = nil
-- 注册红点
-- parentGo: 需要显示红点的物体
function RedDotMgr:Register(key, parentGo)
local node = self:_GetNode(key)
if node then
node:RegisterHandle(parentGo)
--node:Refresh()
end
end
-- 注销红点
function RedDotMgr:UnRegister(key)
if not self:IsNodeExist(key) then
return
end
local node = self:_GetNode(key)
if node then
node:UnRegisterHandle()
end
end
-- 设置红点显示状态(只能设置叶子结点)
function RedDotMgr:SetValid(key, isValid)
local node = self:_GetNode(key)
if node.childList and #node.childList > 0 then
LogError("SetValid Error: 只能设置叶子节点的状态!key=" .. key)
return
end
node:SetValid(isValid)
end
-- 判断key对应的红点是否显示
function RedDotMgr:IsValid(key)
if self:IsNodeExist(key) then
local node = self:_GetNode(key)
return node:IsValid()
end
return false
end
-- 组装Key
function RedDotMgr:PackKey(prefixKey, ...)
local args = { ... }
local key = string.format("%s.%s", prefixKey, table.concat(args, '.'))
return key
end
-- 获取节点
function RedDotMgr:_GetNode(key)
if not self.root then
self.root = RedDotNode.new("Root")
end
local keyList = self:_ParseKey(key)
local node = self.root
for _, name in ipairs(keyList) do
local tempNode = node:GetChild(name)
if not tempNode then
tempNode = node:AddChild(name)
end
node = tempNode
end
return node
end
-- 节点是否存在
function RedDotMgr:IsNodeExist(key)
return self.key_map[key] ~= nil
end
function RedDotMgr:_ParseKey(key)
if string.isNilOrEmpty(key) then
LogError("ParseKey Error: Key不能为空!")
return
end
local keyList = self.key_map[key]
if not keyList then
keyList = string.split(key, '.')
self.key_map[key] = keyList
end
return keyList
end
红点Key定义
--- 如果需要显示数字红点,则在节点key后面加上_NUM
RedDot = {
--- 任务
MainTask = "Task_NUM", -- 主界面任务按钮
Task1 = "Task_NUM.Task1", -- 任务1
Task2 = "Task_NUM.Task2", -- 任务2
--- 活动
MainActivity = "MainActivity", -- 主界面活动按钮
SignIn = "MainActivity.SignIn", -- 签到活动
}
这个红点系统是目前已经在我们项目中使用的红点系统,中间用到了很多框架中封装好的功能,所以直接搬运运行不了是正常的。其中还是有很多可以优化的地方,这里只是提供一个思路。如果有时间的话会尽量整理成C#版本。