【游戏开发实战】手把手教你在Unity中使用lua实现红点系统(前缀树 | 数据结构 | 设计模式 | 算法 | 含工程源码)

一、前言

嗨,大家好,我是新发。
有小伙伴私信我让我写一个红点系统教程,
在这里插入图片描述

可以说,红点系统在游戏中是必备的系统,
在这里插入图片描述
可能有的同学会说,不就是根据条件设置红点的显示吗?
如果你的游戏足够简单,系统不多,那么你可以不考虑写管理层逻辑,怎么简单怎么来。

但实际项目中,模块系统是很多的,情况会相对复杂,比如下面这样子的结构,
在这里插入图片描述

好了,现在我问你,要做到高效、易拓展、易维护,你对这些红点如何进行组织管理?
聪明的小伙伴应该已经看出来了,红点系统很适合使用 这种数据结构来组织,那具体如何实现呢?
本文,我就来讲讲红点系统的具体实现吧~

本文最终效果如下
请添加图片描述

二、环境说明

我之前封装了一个游戏框架:UnityXFramework,我对应的博客文章:《【游戏开发框架】自制Unity通用游戏框架UnityXFramework,详细教程(Unity3D技能树 | tolua | 框架 | 热更新)》
框架中我集成了tolua,业务逻辑使用lua来开发的,我打算在框架中去实现红点系统(使用lua来实现)

注:建议先看我上面这篇UnityXFramework框架教程的文章,这样对你理解下文中我写的lua代码有帮助。

注:如果你想用纯C#实现也可以,只要看懂了本文的原理,相信可以轻松写出C#版本的

三、红点系统设计

1、红点系统规则

首先我们先明确红点系统的规则,一般都是如下的规则:
1 红点的显示方式分两种:带数字和不带数字;
2 如果子节点有红点,父节点也要显示红点,父节点红点数为子节点红点数的和;
3 当子节点红点更新时,对应的父节点也要更新;
4 当所有子节点都没有红点时,父节点才不显示红点。

扫描二维码关注公众号,回复: 13606187 查看本文章

好了,上面我们说了使用 这种数据结构来组织红点数据,而树有很多种,比如红黑树、B+树、霍夫曼树等等,到底用什么树呢?

我们分析一下,首先,使用 来组织,叶子节点可能会超过2,所以 不是二叉树;子节点之间没有顺序关系,所以它是一个棵 无序树;我们要实现高效搜索和修改操作,前缀树 可以满足我们的需求。

2、科普:前缀树

先科普一下什么是前缀树。

前缀树,也叫Trie树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。
它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。

比如我们插入abcabhacg三个单词,在树中的结构是这样:
在这里插入图片描述
那如果我再插入abc,怎么办呢?结构依然是上面那样,节点本身会记录字母出现的次数,比如我们设计节点存储的信息如下:
在这里插入图片描述
所以插入两次abc后,树节点的信息如下,
在这里插入图片描述
当我们要去树中查询abc出现过几次的时候,只需要把abc分割成abc,从根节点依次往下查询是否存在abc,最终返回c节点的endCnt即可,如果想查询以ab为前缀的单词在树中出现了多少次,则分割为ab后,从根节点往下查询ab,然后返回b节点的passCnt即可,这也是前缀树的命名的由来。

3、用前缀树组织红点

我们只需要在上面的基础上,给节点加一个红点数的数据即可,如下
在这里插入图片描述
另外我们通过逻辑来实现父节点的红点数为子节点红点数之和即可。

我们将红点进行规范命名:层级1|层级2|层级3,例Root|ModelA|ModelA_Sub_1,我们把它以|符号分割,然后插入树中,树变成这样子:
在这里插入图片描述
我们再插入一个Root|ModelA|ModelA_Sub_2,树变成这样子:
在这里插入图片描述
我们再插入Root|ModelB|ModelB_Sub_1,树变成这样子:
在这里插入图片描述
假设ModelA_Sub_1节点有一个红点,那么它的父节点ModelA也会有一个红点,同理Root也会有一个红点,如下,
在这里插入图片描述
如果ModelA_Sub_2节点也有一个红点,那么树的状态就是这样子:
在这里插入图片描述
当我们要查询ModelA有多少个红点的时候,则通过Root|ModelA来查询,以|为分割符,从根节点出发,找到ModelA节点后,返回ModelAredpointCnt即为对应的红点数。

好了,下面我们开始动手写代码吧~

四、红点系统具体实现

1、前缀树封装

Assets/LuaFramework/Lua/Logic目录中新建Redpoint文件夹,分别创建RedpointNode.lua脚本和RedpointTree.lua脚本,如下:
在这里插入图片描述

1.1、节点:RedpointNode.lua

RedpointNode脚本代码很简单,提供一个New方法,构造节点,如下:

-- RedpointNode.lua
-- 红点系统,树节点

RedpointNode = RedpointNode or {
    
    }
RedpointNode.__index = RedpointNode

-- 构造节点
function RedpointNode.New(name)
    local self = {
    
    }
    -- 节点名
    self.name = name
    -- 节点被经过的次数
    self.passCnt = 0
    -- 节点作为末尾节点的次数
    self.endCnt = 0
    -- 红点数(子节点的红点数的和)
    self.redpointCnt = 0
    -- 子节点
    self.children = {
    
    }
    -- 红点更新时回调
    self.updateCb = {
    
    }
    setmetatable(self, RedpointNode)
    return self
end
1.2、树:RedpointTree.lua

RedpointTree.lua脚本封装树的行为。

1.2.1、创建根节点

先定义一个根节点root,在Init函数中创建根节点,如下

-- RedpointTree.lua
-- 红点系统树,前缀树结构

RedpointTree = RedpointTree or {
    
    }
local this = RedpointTree

this.root = nil

-- 初始化
function RedpointTree.Init()
	-- 先创建根节点
    this.root = RedpointNode.New("Root")
    -- TODO 构建树结构
    
end
1.2.2、定义节点名

上面TODO要构建树结构,我们需要先定义树节点的名,按层级1|层级2|层级3这种格式命名,

-- RedpointTree.lua

-- 节点名
RedpointTree.NodeNames = {
    
    
    Root = "Root",

    ModelA = "Root|ModelA",
    ModelA_Sub_1 = "Root|ModelA|ModelA_Sub_1",
    ModelA_Sub_2 = "Root|ModelA|ModelA_Sub_2",

    ModelB = "Root|ModelB",
    ModelB_Sub_1 = "Root|ModelB|ModelB_Sub_1",
    ModelB_Sub_2 = "Root|ModelB|ModelB_Sub_2",
}
1.2.3、插入节点

封装一个InsertNode方法,提供插入节点的功能,如下

-- RedpointTree.lua

-- 插入节点
function RedpointTree.InsertNode(name)
    if LuaUtil.IsStrNullOrEmpty(name) then
        return
    end
    if this.SearchNode(name) then
        -- 如果已经存在,则不重复插入
        log("你已经插入过节点了, name: " .. name)
        return
    end

    -- node从根节点出发
    local node = this.root
    node.passCnt = node.passCnt + 1 
    -- 将名字按|符号分割
    local pathList = LuaUtil.SplitString(name, "|")
    for _, path in pairs(pathList) do
        if nil == node.children[path] then
            node.children[path] = RedpointNode.New(path)
        end
        node = node.children[path]
        node.passCnt = node.passCnt + 1
    end
    node.endCnt = node.endCnt + 1
end
1.2.4、查询节点

其中SearchNode是搜索节点,代码如下

-- RedpointTree.lua

-- 查询节点是否在树中并返回节点
function RedpointTree.SearchNode(name)
    if LuaUtil.IsStrNullOrEmpty(name) then
        return nil
    end
    local node = this.root
    local pathList = LuaUtil.SplitString(name, "|")
    for _, path in pairs(pathList) do
        if nil == node.children[path] then
            return nil
        end
        node = node.children[path]
    end
    if node.endCnt > 0 then
        return node
    end
    return nil
end
1.2.5、删除节点

再封装一个删除节点的方法,

-- RedpointTree.lua

-- 删除某个节点
function RedpointTree.DeleteNode(name)
    if nil == this.SearchNode(name) then
        return
    end
    local node = this.root
    node.passCnt = node.passCnt - 1
    local pathList = LuaUtil.SplitString(name, '.')
    for _, path in pairs(pathList) do
        local childNode = node.children[path] 
        childNode.passCnt = childNode.passCnt - 1
        if 0 == childNode.passCnt then
            node.children[path] = nil
            return
        end
        node = childNode
    end
    node.endCnt = node.endCnt - 1
end
1.2.6、修改节点红点数

上面我们提供了节点的插入、查询和删除操作,并没有操作节点的红点数,我们还需要封装一个修改节点红点数的方法,这里我使用的是增量操作,你也可以使用赋值操作,

-- RedpointTree.lua

-- 修改节点的红点数
function RedpointTree.ChangeRedpointCnt(name, delta)
    local targetNode = this.SearchNode(name)
    if nil == targetNode then
        return
    end
    -- 如果是减红点,并且红点数不够减了,则调整delta,使其不减为0
    if delta < 0 and targetNode.redpointCnt + delta < 0 then
        delta = -targetNode.redpointCnt
    end

    local node = this.root
    local pathList = LuaUtil.SplitString(name, "|")
    for _, path in pairs(pathList) do
        local childNode = node.children[path]
        childNode.redpointCnt = childNode.redpointCnt + delta
        node = childNode
        -- 调用回调函数
        for _, cb in pairs(node.updateCb) do
            cb(node.redpointCnt)
        end
    end
end
1.2.7、设置红点更新回调函数

上面修改红点数时,会调用节点的updateCb回调,方便我们更新UI界面的红点,这里我们封装一个设置回调的方法,

-- RedpointTree.lua

-- 设置红点更新回调函数
-- name: 节点名
-- key: 回调key,自定义字符串
-- cb: 回调函数
function RedpointTree.SetCallBack(name, key, cb)
    local node = this.SearchNode(name)
    if nil == node then
        return
    end
    node.updateCb[key] = cb
end
1.2.8、查询节点红点数

我们UI上要显示红点数量,需要查询模块的红点数,我们封装一个查询红点的方法,如下

-- RedpointTree.lua

-- 查询节点的红点数
function RedpointTree.GetRedpointCnt(name)
    local node = this.SearchNode(name)
    if nil == node then
        return 0
    end
    return node.redpointCnt or 0
end
1.2.9、构建树

我们回到Init方法中,构建整颗前缀树,并插入一些红点数据,如下

-- RedpointTree.lua

function RedpointTree.Init()
    -- 先创建根节点
    this.root = RedpointNode.New("Root")
    -- 构建前缀树
    for _, name in pairs(RedpointTree.NodeNames) do
        this.InsertNode(name)
    end

    -- for test-----------------------------------------------
    -- 塞入红点数据
    this.ChangeRedpointCnt(this.NodeNames.ModelA_Sub_1, 1)
    this.ChangeRedpointCnt(this.NodeNames.ModelA_Sub_2, 1)
    this.ChangeRedpointCnt(this.NodeNames.ModelB_Sub_1, 1)
    this.ChangeRedpointCnt(this.NodeNames.ModelB_Sub_2, 1)
end
1.2.10、调用初始化方法

我们在Main.lua脚本中加上红点树的Init方法调用,如下

-- Main.lua

function Main.Init()
    log("Lua Main.Init")
    -- 红点系统
    RedpointTree.Init()

	...
end

到此,我们的红点系统的数据组织逻辑就全部写完了,接下来就是UI部分了。

2、UI界面制作

2.1、大厅界面

我们在大厅界面加上红点UI,如下,这个红点系统模块的入口,就是Root节点,当然,实际项目中这个Root节点应该是不可见的,一般是Root的一级子节点作为各自模块入口来显示红点,这里我只是为了演示,做了一个红点系统的入口,
在这里插入图片描述

2.2、红点系统界面

我们再做一个红点系统界面,专门来测试红点,界面如下,左侧两个标签页:ModelAModelB,分别作为两个模块。
模块里面又有两个子按钮,比如ModelA模块中有ModelA_Sub_1ModelA_Sub_2
在这里插入图片描述
节点关系如下
在这里插入图片描述

3、UI代码

3.1、大厅入口红点

我们先使用PrefabBinder将大厅红点系统入口的红点文本进行对象绑定,方便在代码中获取UI对象,如下
在这里插入图片描述
接着我们打开大厅的脚本GameHallPanel.lua,在SetUi方法中添加红点UI的逻辑,如下

-- GameHallPanel:SetUi.lua

...

local RT = RedpointTree

-- UI交互
function GameHallPanel:SetUi(binder)
	...
	
	-- 获取红点文本UI对象
    self.redpointText = binder:GetObj("redpointText")
    -- 注册红点回调
    RT.SetCallBack(RT.NodeNames.Root, "Root", function (redpointCnt)
        self:UpdateRedPoint(redpointCnt)
    end)
    self:UpdateRedPoint(RT.GetRedpointCnt(RT.NodeNames.Root))
end

-- 更新红点
function GameHallPanel:UpdateRedPoint(redpointCnt)
    self.redpointText.text = tostring(redpointCnt)
    LuaUtil.SafeActiveObj(self.redpointText.transform.parent, redpointCnt > 0)
end

因为我们在RedpointTree.lua脚本的Init函数中已经塞入了红点数据,如下
在这里插入图片描述
所以,理论上我们Root节点应该是有四个红点,我们运行看看,
请添加图片描述
可以看到,显示正确,下面,我们去写红点系统界面内部的代码吧。

3.2、红点系统界面

首先给红点系统界面绑定UI对象,如下
在这里插入图片描述
注册界面资源,分配一个资源ID,方便我们通过资源ID实例化界面,
在这里插入图片描述
接着,在Assets/LuaFramework/Lua/View目录中创建Redpoint文件夹,再创建一个RedpointPanel.lua脚本,如下
在这里插入图片描述
首先编写界面的常规方法,如下

-- RedpointPanel.lua
-- 红点系统界面

RedpointPanel = RedpointPanel or {
    
    }
RedpointPanel.__index = RedpointPanel

-- 红点数
local RT = RedpointTree

-- 界面对象
local instance = nil

-- 显示界面
function RedpointPanel.Show()
    instance = UITool.CreatePanelObj(instance, RedpointPanel, 'RedpointPanel', PANEL_ID.REDPOINT_PANEL_ID, GlobalObjs.s_windowPanel)
end

-- 关闭界面
function RedpointPanel.Hide()
    UITool.HidePanel(instance)
end

-- 界面显示回调
function RedpointPanel:OnShow(parent)
	-- 实例化界面预设,资源ID是15
    local panelObj = UITool.Instantiate(parent, 15)
    self.panelObj = panelObj
    local binder = panelObj:GetComponent("PrefabBinder")
    -- 设置UI交互
    self:SetUi(binder)
end

-- UI交互
function RedpointPanel:SetUi(binder)
    UGUITool.SetButton(binder, "closeBtn", function (btn)
        self.Hide()
        LoginLogic.DoLogout()
    end)
    self.propItem = binder:GetObj("propItem")
    
	-- TODO 获取UI对象

    -- TODO 注册红点更新回调
    
end

function RedpointPanel:OnHide()
    LuaUtil.SafeDestroyObj(self.panelObj)
    instance = nil

    -- TODO 注销红点回调

end

我们通过binder来获取绑定的UI对象,如下,

-- RedpointPanel.lua

function RedpointPanel:SetUi(binder)
	...
	
    self.modelARedpointText = binder:GetObj("modelARedpointText")
    self.modelBRedpointText = binder:GetObj("modelBRedpointText")
    self.modelASub1Redpoint = binder:GetObj("modelASub1Redpoint")
    self.modelASub2Redpoint = binder:GetObj("modelASub2Redpoint")
    self.modelBSub1Redpoint = binder:GetObj("modelBSub1Redpoint")
    self.modelBSub2Redpoint = binder:GetObj("modelBSub2Redpoint")
    
    ...
end

接着我们设置红点更新回调,并获取当前红点数据设置红点UI,以ModelA为例,

-- RedpointPanel.lua

function RedpointPanel:SetUi(binder)
	... 
	
 	-- 注册红点更新回调
    RT.SetCallBack(RT.NodeNames.ModelA, "ModelA", function (redpointCnt)
        self:UpdateRedPoint_ModelA(redpointCnt)
    end)
    self:UpdateRedPoint_ModelA(RedpointTree.GetRedpointCnt(RT.NodeNames.ModelA))
    
    ...
end

function RedpointPanel:UpdateRedPoint_ModelA(redpointCnt)
    self.modelARedpointText.text = tostring(redpointCnt)
    LuaUtil.SafeActiveObj(self.modelARedpointText.transform.parent, redpointCnt > 0)
end

最后,在界面关闭时记得注销红点回调,

-- RedpointPanel.lua

function RedpointPanel:OnHide()
	...

    -- 注销红点回调
    RT.SetCallBack(RT.NodeNames.ModelA, "ModelA", nil)
    
	...
end

然后,我希望点击模块Sub按钮的时候会扣除对应的红点,我们加上对应的逻辑,

-- RedpointPanel.lua

function RedpointPanel:SetUi(binder)
	... 
	
	-- 点击ModelA_Sub_1按钮,减少对应的红点
    UGUITool.SetButton(binder, "modelASub1Btn", function (btn)
        RT.ChangeRedpointCnt(RT.NodeNames.ModelA_Sub_1, -1)
    end)

	...
end

依次编写其他红点UI逻辑即可。

五、运行测试

最后,运行测试效果如下,可以看到,我们已经实现了红点系统的功能了,
请添加图片描述

六、开源项目

本文项目源码已在GitCode开源,感兴趣的同学可自行下载学习,如果喜欢记得给我一个星星,感激万分~
项目源码地址:https://codechina.csdn.net/linxinfa/UnityXFramework
在这里插入图片描述

七、完毕

好啦,就到这里吧~
我是林新发:https://blog.csdn.net/linxinfa
原创不易,若转载请注明出处,感谢大家~
喜欢我的可以点赞、关注、收藏,如果有什么技术上的疑问,欢迎留言或私信~

猜你喜欢

转载自blog.csdn.net/linxinfa/article/details/121899276