【新手引导分享】新手引导结构分享

此篇分享文是在论坛分享的新手引导框架的基础上进行的修改适用于更多的项目需求,我将使用简明的语言来描述实现的过程。

准备-在项目目录中增加async库,具体操作如下:
在你的项目的根目录下使用 npm install async 安装async库,安装完成之后,你的项目的根目录下应该有这样的目录文件

Snipaste_2020-12-23_10-46-37
接着,就可以在代码中,使用 let async = require(‘async’) 引用这个库了,使用如图:

新手引导功能思路

第一个大头部分 mask组件的使用

一般来说,无非就是,用一个mask组件,一个手指,然后引导手指到需要引导的节点上,为什么需要mask组件呢?因为新手引导期间,玩家只能点击引导流程中允许他点击的节点,所以需要使用mask组件来屏蔽其他事件,再加上mask有个“反向”的操作,如图
Snipaste_2020-12-23_10-51-29
含义就是,还是举例子描述

比如我们新建一个空节点,给节点添加mask组件,在组件下添加一张sprite图片,如果此时我们没有勾选反向,那么它的作用就是,当我们调整mask 大小的时候,超出mask大小之外的sprite内容不会显示,就像这样:

Snipaste_2020-12-23_10-55-33

当我们勾选了反向之后,效果就像这样:

Snipaste_2020-12-23_10-56-27

以上就是mask在新手引导期间的作用了。当然如果你不喜欢,或者项目需求不是方框类型,你也可以改成圆形,也有项目需要不需要把mask显示出来的(这种需求就更不用操心是选择rect还是ellipse了)

第二个大头部分,就是引导的动画
引导动画这分为两种

  • 1.单纯的引导手指过去

  • 2.在给定的点位之间来回移动的,比如引导拖动a物品到b物品处

其实,这两种动画都可以用Creator的Action的动作系统实现,比如第一种可以这样,如图:


第二种动画的喜实现,如图:

当动画的问题解决之后,接下来,就是如何解决引导的指令和引导的流程的问题了

第三个小头部分,节点查找

既然我们要做引导,那必然需要使用到查找节点,比如说,我要引导玩家点击主界面的任务icon,那么任务icon的路径我们是能够知道的,假如任务icon的路径为 ‘bottom/ButtonContent/btnTask’,那么查找的实现逻辑,如下图,这里需要使用两个函数,一个是对路径字符串进行拆分,一个是查找并返回查找到的节点


当我们的节点被查找到之后,就可以将手指移动过去了(如果是来回移动的动画,举一反三,也能实现了)

第四个大头部分,引导的指令函数和引导的配置

什么是引导指令?这可能是我自己的定义,比如引导过程中单纯的手指移动就叫MoveFinger,手指在两点之间或者多点之间来回移动加MoveFingerRepeat,不同的指令,不同的需求,使用不同的引导指令函数来处理,说到这里,也不得不要说下引导的配置了,什么是引导的配置,举例子:

引导的配置文件,正如上图中所说,将是一个ts类型的,语法结构如图,你的项目具体有多少个引导配置,由你们策划需要而定,比如新手引导,需要引导玩家点击升级、任务、背包使用、使用药品等等,我建议是每个引导写一个如图那样的引导配置。

这个时候,我们之前导入的async库作用就来了,在这个库中,我们只需要使用一个非常好用的函数:

async.series
具体使用为:
async.series({
** flag1:function(done){ //flag1 是一个流程标识,用户自定义**
** //逻辑处理**
** done(null,返回结果)// 第一个参数是异常错误,第二个参数的返回结果**
** },**
** flag2:function(done){**
** //逻辑处理**
** done(‘error info’,null) //如果返回错误信息,**
** //下面的流程控制将会被中断,直接跳到最后结果函数**
** },**
},function(error,result){
** //最后结果**
** //result是返回结果总集,包含了所有的流程控制 ,**
** //result.flag1 可以获取标识1中处理的结果**
});
上面的代码看不进去?没关系,我将使用用一张图来简单解释一下这个函数的作用,如图:


那么async.series这个函数的作用大概就是如此。

第五个大头部分,如何使用mask来拦截不是点击在引导节点上的事件

我们在上面说过,mask可以拦截事件,并且也介绍了反向这个操作的使用,那么在这里我们就需要实现一个功能,就是:我们只拦截玩家没有点击在引导目标身上的事件,如果玩家点击到了引导的目标,那就让这个事件往下穿透,我也查了很多的帖子,基本上都是这个实现思路,在这里,我新建了一个节点,名字叫做“GuideTaskMgr”,用来处理所有的引导任务,在这个节点下的结构如图所示:
Snipaste_2020-12-23_11-53-09

创建一个脚本,名字叫:GuideTaskMgr,并将这个脚本挂在GuideTaskMgr节点上,给GuideTaskMgr节点注册上下面这个事件:

至此引导前期需要准备的知识和逻辑就都做完了,接下来是,如何将上面这些东西组合起来,实现一个好用的新手引导功能

完整的GuideTaskMgr类

const { ccclass, property } = cc._decorator;

declare let require: (string) => any;

let async = require(‘async’);

@ccclass

export class GuideTaskMgr extends cc.Component {

/**手指预制件 */

@property(cc.Prefab)

FINGER_PREFAB: cc.Prefab = null;

_finger: cc.Node = null;

_mask: cc.Mask;

_maskInfo: cc.Node; //调试面板(查看挖洞是否正常)

_targetNode: any; //引导的目标(也就是要引导玩家操作的目标)

_isOpenMaskInfo: boolean = true; //是否打开调试面板

_task: any;//当前引导任务

static readonly FINGER: string = 'movefinger';//指令注册

static readonly typeList = [

    GuideTaskMgr.FINGER,

];

onLoad() {

    if (this.FINGER_PREFAB) {

        this._finger = cc.instantiate(this.FINGER_PREFAB);

        this._finger.parent = this.node;

        this._finger.active = false;

        //this._finger.getComponent("GuideFinger").stopAnim(); //手指可以挂一些动画脚本

    }

    this.node.setContentSize(cc.winSize);

    cc.systemEvent.on("ExcuteGuideTask", this.ExcuteGuideTask, this); //注册引导事件

    this.node.on(cc.Node.EventType.TOUCH_START, this.addSetSwallowTouchesEventListener, this);

    this._mask = this.node.getComponentInChildren(cc.Mask);

    this._maskInfo = this._mask.node.getChildByName("info");

    this._mask.node.active = false; //mask遮挡面板默认不开启,只有在引导时在开启

    this._maskInfo.active = this._isOpenMaskInfo;

}

/**

 * 由事件进行派发的引导处理

 * @param data 

 */

ExcuteGuideTask(data) {

    console.log("接受的引导数据", data);

    this._mask.node.active = true;  //引导前开启遮挡面板

            

    let flie = data.taskFlie; //要执行的引导文件

    let index = data.stepIndex;//要执行的步骤

    let { task } = require(flie);

    // this._task = task;

    let step = task.steps[index]; //取得要执行的步骤

    this._targetNode = null; //每次引导执行前,都将之前的引导目标清空

    //调用async.series来执行引导的步骤

    async.series({

        stepStart(markonCb) {

            if (step.onStart) {

                step.onStart(() => {

                    markonCb();

                });

            } else {

                markonCb();

            }

        },

        stepExcute: (markonCb) => {

            if (step.onExcute) {

                step.onExcute(() => {

                    this.scheduleOnce(() => {

                        let cmd = GuideTaskMgr[step.command.cmd];

                        if (cmd) {

                            cmd(this, step, (error) => {

                                markonCb(error);

                            });

                        }

                    }, step.delayTime || 0);

                });

            }

        },

        stepEnd: (markonCb) => {

            if (step.onEnd) {

                step.onEnd(() => {

                    markonCb();

                });

            } else {

                markonCb();

            }

        },

    },

        (error) => {

            if (error) {

                //如果存在意外终止 doSomething。。。。

            }

            //引导执行完毕

            this._mask._graphics.clear();

            this._mask.node.active = false;//关闭遮挡面板

            this._finger.active = false;

        })

}

/**

 * 事件的吞没处理机制

 */

addSetSwallowTouchesEventListener(event) {

    if (!this._mask.node.active) {

        this.node._touchListener.setSwallowTouches(false);

        return;

    }

    if (!this._targetNode) {

        this.node._touchListener.setSwallowTouches(true);

        return;

    }

    if (!cc.isValid(this._targetNode)) {

        return;

    }

    let rect = this._targetNode.getBoundingBoxToWorld();

    if (rect.contains(event.getLocation())) {

        //如果玩家点击了规定的区域,则让事件往下派发

        this.node._touchListener.setSwallowTouches(false);

    } else {

        this.node._touchListener.setSwallowTouches(true);

    }

}

static movefinger(guideTaskMgr, step, callback) {

    let params = step.command;

    guideTaskMgr._targetNode = null; //先置空之前查找的目标节点

    //开始查找新的目标节点

    guideTaskMgr.find(params.args, (node, rect) => {

        //查找到之后并且聚焦过去

        guideTaskMgr.fingerToNode(node, () => {

            guideTaskMgr._targetNode = node; //赋值新的查找到的目标节点

            node.once(cc.Node.EventType.TOUCH_END, () => {

                callback();

                console.log("点击目标节点成功")

            });

        });

    });

}

//******************工具集函数********************* */

/**

 * 查找节点

 * @param value 

 * @param cb 

 */

private find(value, cb?) {

    let root = cc.find('Canvas');

    this.locateNode(root, value, (error, node) => {

        if (error) {

            console.log("查找节点失败", value)

            return;

        }

        let rect = this._focusToNode(node);

        if (cb) {

            console.log("查找节点成功", value)

            cb(node, rect);

        }

    });

}

/**

* 路径特殊字符使用正则表达式进行拆分

* @param locator 查询的路径配置,形如:'bottom/bag/bagContent/casting',

*/

private parse(locator) {

    let names = locator.split(/[.,//,>,#]/g);

    let segments = names.map(function (name) {

        let index = locator.indexOf(name);

        let symbol = locator[index - 1] || '/';

        return { symbol: symbol, name: name.trim() };

    });

    return segments;

}

/**

* 根据查找路径和根节点定位要查找的目标节点

* @param root 

* @param locator 

* @param cb 

*/

private locateNode(root, locator, cb?) {

    let segments = this.parse(locator);

    let child, node = root;

    for (let i = 0; i < segments.length; i++) {

        let item = segments[i];

        switch (item.symbol) {

            case '/':

                child = node.getChildByName(item.name);

                break;

        }

        if (!child) {

            node = null;

            break;

        }

        node = child;

    }

    if (node && node.active && cb) {

        cb(null, node);

    } else {

        cb(locator)

    }

    return node;

}

/**

 * 聚焦到目标节点并绘制图形

 * @param node 查找的节点

 */

_focusToNode(node: cc.Node) {

    this._mask._graphics.clear();

    let rect = node.getBoundingBoxToWorld();

    let p = this.node.convertToNodeSpaceAR(rect.origin);

    rect.x = p.x;

    rect.y = p.y;

    this._mask._graphics.fillRect(rect.x + 10, rect.y + 10, rect.width - 15, rect.height - 15);

    return rect;

}

/**

 * 移动手指到目标节点

 * @param node 

 * @param markonCb 

 */

fingerToNode(node, markonCb) {

    if (!this._finger) {

        markonCb();

    }

    this._finger.active = true;

    this._finger.stopAllActions();

    let p = this.node.convertToNodeSpaceAR(node.parent.convertToWorldSpaceAR(node.position));

    this._finger.setPosition(p);

    //this._finger.getComponent("GuideFinger").playAnim(); //手指可以挂一些动画脚本,用来控制动画播放

    markonCb();

}

}

项目demo在如下:
NewProject.zip (487.8 KB)

总结:

**在管理类中,我使用了事件的形式来执行新手引导的流程,如图

为什么要这么做,这是因为:如果你的项目引导需求中,有很多是异步处理的逻辑,在原先论坛引导的框架代码中,是使用了delayTime来触发下一次引导,但是异步操作返回结果的时间是不确定定的,所以就会出现,异步的结果还没有返回,比如界面的加载,这个时候执行下一步引导,会导致查找节点失败的情况。

使用事件的好处在于,将引导的过程拆分,由事件进行控制,不过需要个人对于整个引导的流程清晰把握,至于最终如何取舍,就看个人的选择了。**

8赞

这个是cocos 星球的那个方案,问题时,都是强制引导,没法中断

使用事件之后,就可以灵活处理了,之前用那套方案,发现了很多问题,不过倒是提供了一种很不错的实现思路

支持部分暂停吗?现在缺一个这样的功能

支持啊,这篇文章的目的就是为了灵活而写的

查找节点用节点路径跟界面耦合性太高了,换个人维护改个层级、名字引导就凉了。我们是在有引导的节点上挂个组件,把这个节点注册到引导管理器去,这样常见的改名和调整层级操作不会影响引导。类似

let GuideManager = require("GuideManager")

cc.Class({
    extends: cc.Component,
    properties: {
        guideNodeName: "",
    },

    onEnable: function () {
        GuideManager.registerGuideNode(this.guideNodeName, this.node)
    },
    onDisable: function () {
        GuideManager.registerGuideNode(this.guideNodeName, null)
    },
})
1赞

是的,使用查找节点的方式,存在这样的弊端,就像你说的,如果有开发者,或者策划后续要修改ui节点层级,就要重新编辑路径

添加的方式也是个解决方案,层级不会影响遮罩么?我记得处理的时候,引导节点都放在所有界面顶部,然后挖空处理点击事件

我在用的时候,延时和事件都用了,延时最省事,事件处理起来要考虑到界面的加载顺序,以及框架的一些限制。

请问 多点触摸处理了吗

怎么显示每个步骤的提示文本啊

该主题在最后一个回复创建后14天后自动关闭。不再允许新的回复。