刚花了两天重构了下手头项目中的红点系统,就写一点分享下自己的设鸡吧。
语言:TypeScript
引擎:Laya + FairyGUI
一、红点系统设计思路
有句话怎么说来着,所谓工欲善其事,必先利其器!所以先想明白自己想要的究竟是什么很重要。个人认为,红点系统即然作为一个系统,就应当是独立的,作为一个呆在框架底层中的模块,必定要与业务耦合,这是大前提,但是考虑如何解藕其实是最后才需要考虑的事情,咱得先有红点功能才能考虑如何解耦不是?
设计思路是基于需求的,先给它定个需求吧,我列了下大致也就两点:
1. 管理红点树
2. 自动管理UI中的红点
这两个需求都只是站在程序角度上考虑的,本篇也只阐述如何实现最基础的红点树,即然需求已经明确了,接下来就开始思考如何实现需求了
二、红点系统需要实现的接口功能:
1. 提供注册红点节点关系的接口
2. 提供刷新节点红点值的接口
3. 提供红点入口的注册与注销接口
不过在设计接口之前,先定义下红点类型枚举吧
enum RedDotType {
// 圆点
DOT,
// 数字
NUM
}
基于上面的接口功能描述,接下来就可以定义接口文件了:
interface IRedDotSystem {
// 构建红点树
init(): void;
// 设置红点数
setRedNum(key: eRedPointTreeNodeName, num: number): void;
/**
* 将红点与入口相关联
* 1. 每个入口至多允许关联一个红点
* 2. 每个红点允许同时关联多个入口
*/
attach(key: eRedPointTreeNodeName, obj: fgui.GComponent): void;
}
接口中的方法比接口功能设计中列举的要少,这是因为接口文件是对外的,对于业务层来说,有这三个接口就已经完全够用了,毕竟业务层也没有获取红点值的必要。
三、红点系统的具体实现
1. 红点绘制函数,直接一个函数搞定数字和圆点类型
class RedDotUtils {
// 绘制红点
static showRedDots(obj:fgui.GComponent, num:number, smart:boolean):void
{
if (num < 1)
// 移除红点
else if (num === 1 && smart === true)
// 绘制圆点
else
// 绘制数字
}
}
2. 红点树核心类 RedPointSystem ,不要在意命名的不统一,因为重构时不想改文件名啊,不然以后日志不好查
class RedPointSystem extends Singleton<IRedDotSystem>() implements IRedDotSystem {
/** 根节点 */
private mRoot: RedPointTreeNode = null;
/** 名字与节点映射关系 */
private mNodeMap: { [name: number]: RedPointTreeNode } = {};
/** 视图与红点关联表,每个视图仅允许关联一个红点 */
private mDict: RDictionary<fgui.GObject, eRedPointTreeNodeName> = new RDictionary();
createNode(key: eRedPointTreeNodeName, type: RedDotType): void {
this.mNodeMap[key] = new RedPointTreeNode(key, type);
}
setParent(key: eRedPointTreeNodeName, parentKey: eRedPointTreeNodeName): void {
this.mNodeMap[key].setParent(this.mNodeMap[parentKey]);
}
setRedNum(key: eRedPointTreeNodeName, num: number): void {
this.mNodeMap[key].num = num;
}
attach(key: eRedPointTreeNodeName, obj: fgui.GComponent): void {
// 优先将历史关联取消,这样如果没关联成功红点会消失,否则在逻辑上会出现我已重新关联但还是显示旧的红点值的情况
const oldKey = this.mDict.get(obj) || eRedPointTreeNodeName.NIL;
if (oldKey !== eRedPointTreeNodeName.NIL) {
// 重复关联
if (oldKey === key) {
return;
}
// 取消历史关联
else {
this.detach(obj);
}
}
this.mDict.set(obj, key);
this.mNodeMap[key].attach(obj);
}
detach(obj: fgui.GComponent): void {
this.mDict.delete(obj);
this.mNodeMap[key].detach(obj);
}
}
这个类实现的是红点系统的系统接口,简单的作下说明
2.1. 由于红点树的构建方式在后期结构的时候发生了变化,故由原来的 createPrimaryNode(key) + createNode(key, parentKey) 变成了现在的 createNode(key) 和 setParent(key, parentKey)
2.2. 向数据层开放 setRedNum(key,num) 接口来改变红点值,向视图层开放 attach(key, obj) 的接口来注册红点入口,红点的使用对于业务层来说就是这么简单
2.3. 这个类是个单例类,调用方式比如 RedPointSystem.inst.init(); 这个方法用于在数据层初始化的时候构建红点树,将在下面提到
3. 接下来是红点节点类 RedPointTreeNode
class RedPointTreeNode {
// 都是构造函数里传进来的参数,没什么好看的
/** 父节点 */
private mParent: RedPointTreeNode = null;
/** 子节点列表 */
private mChildren: RedPointTreeNode[] = [];
/** 当前节点红点值(不包括子节点) */
private mNum: number = 0;
/** 视图节点 */
private mObj: fgui.GComponent = null;
/** 红点托管组件 */
private mComp: RedPointAttachScript = null;
constructor(id: eRedPointTreeNodeName, type: RedDotType) {
// 都是赋值,没什么好看的
}
setParent(node: RedPointTreeNode): void {
this.mParent = node;
// 将自己添加至父级列表中
if (node) {
node.mChildren.push(this);
}
}
attach(obj: fgui.GComponent): void {
this.mObj = obj;
this.mComp = obj.displayObject.getComponent(RedPointAttachScript);
if (!comp) {
this.mComp = obj.displayObject.addComponent(RedPointAttachScript);
}
this.mComp.attach(obj, this);
}
/**
* @param v 设置当前节点的红点数量
* @param force 强制刷新红点
*/
updateRedNum(v: number): void {
if (this.mNum !== v) {
this.mNum = v;
this.refreshRedNum();
}
}
/**
* 刷新红点数
*/
private refreshRedNum(): void {
this.mComp.updateRedNum();
if (this.mParent.mId !== eRedPointTreeNodeName.ROOT) {
this.mParent.refreshRedNum();
}
}
detach(obj: fgui.GComponent): void {
this.mComp.attach(null, null);
this.mObj = null;
this.mComp = null;
}
private sumChildren(): number {
let sum = 0;
for (let i = 0; i < this.mChildren.length; i++) {
sum += this.mChildren[i].num;
}
return sum;
}
readonly id: eRedPointTreeNodeName;
readonly type: RedDotType;
get num() {
return this.mNum + this.sumChildren();
}
set num(v: number) {
v = Math.max(0, v);
this.updateRedNum(v);
}
}
4. 最后就是视图脚本代码了,红点树在 attach 的时候会自动为红点入口挂上这个脚本,这样入口在销毁的时候就会自动与红点取消关联了,红点树的实现致此完成。
class RedPointAttachScript extends Laya.Script {
private mObj: fgui.GComponent = null;
private mNode: RedPointTreeNode = null;
private mEnabled: boolean = false;
onEnable(): void {
this.mEnabled = true;
this.updateRedNum();
}
onDisable(): void {
this.mEnabled = false;
}
onDestroy(): void {
RedPointSystem.inst.detach(this.mObj);
}
updateRedNum(): void {
if (this.mObj && this.mNode && this.mEnabled) {
const num = this.mNode.num;
const shap = this.mNode.shap;
if (shap === RedDotShap.DOT) {
if (num > 0) {
RedDotsUtils.showRedDots(this.mObj, 1);
}
else {
RedDotsUtils.showRedDots(this.mObj, 0);
}
}
else if (shap === RedDotShap.NUM) {
RedDotsUtils.showRedDots(this.mObj, num, false);
}
}
}
/**
* 关联红点节点,在脚本被启用时自动启用红点,在脚本被禁用或销毁时,自动停用红点
*/
attach(obj: fgui.GComponent, node: RedPointTreeNode): void {
this.mObj = obj;
this.mNode = node;
this.updateRedNum();
}
}
四、其它扩展
1. 引导性红点
大部分游戏和应用中都有这么一类红点,就是首次功能开启或首次登陆的时候会有个引导红点,点一下就会消失,其实这个功能是可以内置到红点树中来实现的
2. 红点开关
有些功能可能要到达一定的条件才会开启,但基于配置构建出来的红点树的结构是固定的,这可能会导致有些没有开启的功能虽然没有入口,但是在父级入口那里也会显示个红点,值得思考
3. 刷新优化
目前只要一个节点刷新了,所有祖先节点都会重新计算,这个关乎性能,可以考虑优一优
4. 红点系统究竟是树还是图?
由于游戏中挺多功能往往都有多个入口,所以其实我个人觉得红点图更科学
5. 应该还有其它,但我没有想到
五、红点图的构建
在上面的设计中,红点系统公开出来的接口是够简单,但是红点树如何构建,这个我却不知道自己的做法是否是科学的,说来惭愧,码字十几年有余,我只在两个项目里接触过红点系统,上个项目中有红点但并没有红点系统,UI里到处都是红点值计算函数,到处都是类似
if (this.hasRedNum()) {
RedDotsUtils.showReadDot(this.btnXXX, 1);
}
的代码,另一个就是现在的项目,虽然有红点树但一点都不好用——它只有UI出来的时候才能刷新红点值!!!这是种本末倒置的做法呀,正常不管有没UI,红点值都应该是完整的才对,这也是我重构红点系统的原因,不扯远了,贴下我目前红点树的配置方式吧:
export const enum eRedPointTreeNodeName {
/** 未指定,请勿修改或删除 */
NIL = 0,
/** 最小值 */
MIN_VALUE,
/** 根节点,请勿修改或删除 */
ROOT = MIN_VALUE,
/** 观影有礼入口 */
WATCH_ADS_ENTRY,
/** 每日观影 */
WATCH_ADS_DAILY,
/** 七日观影 */
WATCH_ADS_DAYS7,
/** 累计观影 */
WATCH_ADS_TOTAL,
/** 最大值 */
MAX_VALUE
}
/**
* 红点树配置 [key,type,parent]
*/
const RedDotCfgArr: number[][] = [
// 根节点
[eRedPointTreeNodeName.ROOT, RedDotType.DOT, eRedPointTreeNodeName.NIL],
// 观影有礼
[eRedPointTreeNodeName.WATCH_ADS_ENTRY, RedDotType.DOT, eRedPointTreeNodeName.ROOT],
[eRedPointTreeNodeName.WATCH_ADS_TOTAL, RedDotType.NUM, eRedPointTreeNodeName.WATCH_ADS_ENTRY],
[eRedPointTreeNodeName.WATCH_ADS_DAYS7, RedDotType.NUM, eRedPointTreeNodeName.WATCH_ADS_ENTRY],
[eRedPointTreeNodeName.WATCH_ADS_DAILY, RedDotType.NUM, eRedPointTreeNodeName.WATCH_ADS_ENTRY]
// 至此结束
[eRedPointTreeNodeName.MAX_VALUE]
];
然后在用户登陆游戏后,立刻根据这个配置来构建一棵完整的红点树,如果是图的话,就需要检查是否存在循环依赖问题
不过有听说红点树是由策划配置的,但在我眼里,游戏的界面是经常变动的,各种列表、入口等等都处于不同 UI 甚至弹窗中,我想像不出来如何把他们做到文档里去配置,有知道的大哥可否分享下那是怎么实现的?非常感谢