游戏中的红点系统

刚花了两天重构了下手头项目中的红点系统,就写一点分享下自己的设鸡吧。

语言: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 甚至弹窗中,我想像不出来如何把他们做到文档里去配置,有知道的大哥可否分享下那是怎么实现的?非常感谢

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值