cat
Shioho

# 前言

哈喽哇,新年快乐呀,作为一只不合格的鸽子,一有空就立刻来写文章啦,这次分享些什么好哩。想了想,最近又接手了新手引导的开发,那就来写一写这个挺恶心的模块的心得吧!!!

# 需求分析

又总所周知,新手引导之所以是一个很恶心的模块,其根本在于其模块过于业务化,基本一个游戏就要为其写一个引导流程,这就会导致在业务内嵌入大量的新手引导代码,这是违反开放封闭原则的,而且新手引导的改动频率还十分频繁,如果在业务模块内嵌入太多的代码的话,在频繁改动时你会发现,原先好好的业务写着写着变的面目全非,乱糟糟的了。为了解决嵌入过多的问题且最好做到模块复用性高,我就决定采用节点搜索 + 引导实例流程配置的方法进行解决。但如果是那种玩法很复杂,而且流程巨多的引导的话,这边还是建议新建一个场景用来专门实现吧。以下会根据 弱引导 以及 强引导 两种流程来进行新手引导的设计实现。

前排先放上项目地址哈:cocos-guide,如果觉得有帮助的话麻烦点一颗小星星哈~

# 代码分析与实现

# 接口

  • IGuideStep
    引导步骤
export interface IGuideStep {
    //id 不用手动设置,会自动赋值,所以设置为可空
    id?: number;
    // 备注,debug 用
    desc: string;
    // 前置引导步骤索引,用于在退出中断时,重新进行这一步之前可能需要一些前置步骤
    preSteps?: number[];
    // 详细命令
    command: IGuideCommand;
    // 是否需要同步至服务端
    isSaveServer?: boolean;
    // 延迟执行
    delay?: number;
    onInit?: Function;
    onStart?: Function;
    onEnd?: Function;
}
  • IGuideConfig
    引导配置
export interface IGuideConfig {
    id: number;
    name: string;
    // 是否执行引导,可配置局部跳过
    isRunGuide: boolean;
    // 是否需要本地存储,如果缓存了游戏启动时就会自动运行引导,不再判断触发条件
    isLocalSave: boolean;
    // 执行顺序,排队 or 立刻执行
    exeSequence: GuideExeSequence;
    // 前置步骤 Map,不会进行保存
    preStepsMap?: Map<number, IGuideStep[]>;
    // 正式执行步骤
    steps: IGuideStep[];
    onComplete?: Function;
}
  • IRuningGuide
    运行时引导对象,用于执行步骤及判断引导是否完成
export interface IRuningGuide {
    config: IGuideConfig;
    // 是否在前置步骤阶段
    isInPreStep: boolean;
    stepIdx: number;
    // 当前 stepIdx 需要执行的所有步骤
    steps: IGuideStep[];
}

# guideDataCtrl

数据类,用于保存引导数据完成情况,现在就都存在本地了,暂时不想写服务端同步

export class guideDataCtrl {
    // 记录在案的触发了但未完成的引导
    private _localGuides: number[];
    // 已经完成了的引导
    private _completeGuides: GuideCache[];
    public get localGuides(): number[] {
        return this._localGuides;
    }
    public init() {
        this._localGuides = [];
        this._completeGuides = [];
        //TODO 从服务端获取引导数据更新_completeGuides
    }
    public setCompleteStep(guideId: number, stepId: number) {
        //TODO 同步至服务端
        // 缓存至本地
        var cache = this.getCompleteCache(guideId);
        if (cache) {
            cache.StepId = stepId;
        } else {
            var newCache = new GuideCache();
            newCache.Id = guideId;
            newCache.StepId = stepId;
            this._completeGuides.push(newCache);
        }
        this.save();
    }
    public saveLocalGuide(id: number) {
        if (this._localGuides.indexOf(id) >= 0)
            return;
        this._localGuides.push(id);
        this.save();
    }
    public getCompleteStepId(guideId: number): number {
        var cache = this.getCompleteCache(guideId);
        return !!cache ? cache.StepId : -1;
    }
    public localGuideSave(id: number) {
        if (this._localGuides.indexOf(id) >= 0)
            return;
        this._localGuides.push(id);
        this.save();
    }
    public localGuideComplete(id: number) {
        var idx = this._localGuides.indexOf(id);
        if (idx < 0)
            return;
        this._localGuides.splice(idx, 1);
        this.save();
    }
    private getCompleteCache(guideId: number): GuideCache {
        return this._completeGuides.find(v => v.Id == guideId);
    }
    private save() {
        localStorage.setItem("localGuides", JSON.stringify(this._localGuides));
        localStorage.setItem("completeGuides", JSON.stringify(this._completeGuides));
    }
}
export class GuideCache {
    Id: number;
    StepId: number;
}

# guideMgr

引导管理器类,控制引导的生命周期,接收不同命令,执行对应的中介器函数,为了方便就先用单例写了 (x

public runGuides() {
        // 开始时运行未完成的引导
        for (let i = this._dataCtrl.localGuides.length - 1; i >= 0; i--) {
            var guideId = this._dataCtrl.localGuides[i];
            var cfg = guideCenter.getGuide(guideId);
            this.runGuide(cfg);
        }
    }
    public triggerGuide(guideId: number) {
        //1. 检查该引导是否完成过了
        var cfg = guideCenter.getGuide(guideId);
        var stepId = this._dataCtrl.getCompleteStepId(guideId);
        if (stepId >= cfg.steps.length - 1) {
            // 说明已经完成了
            if (this._isNeedDebug)
                console.log(`引导:${cfg.name} id:${cfg.id}已经完成啦,不在重复执行`);
            return;
        }
        //2. 记录本地运行引导缓存
        if (!!cfg.isLocalSave)
            this._dataCtrl.saveLocalGuide(guideId);
        //3. 根据引导缓存执行引导
        this.runGuide(cfg);
    }
    public cancleGuide(guideId: number) {
        // 取消引导
        var idx = this._guideQueue.findIndex(v => v.config.id == guideId);
        this._guideQueue.splice(idx, 1);
        this._runningGuideMap.delete(guideId);
        if (this._isNeedDebug)
            console.log(`取消引导,id:${guideId}`);
    }
    public stepOver(guideId: number, stepId: number) {
        // 引导中一个步骤完成,可能是前置步骤,也可能是正式步骤
        ...
    }
    private exeGuideStep(guide: IRuningGuide) {
        // 根据正式步骤生成,前置步骤 + 正式步骤的集合,运行完该集合后才表示该正式步骤完成
        ...
    }
    ...

# guideCenter

引导中介类,用于供外部触发、取消引导,并注册引导实例

export class guideCenter {
    private static _guideCfgMap: Map<number, IGuideConfig>;
    static init() {
        this._guideCfgMap = new Map<number, IGuideConfig>();
    }
    static triggerGuide(guideId: number) {
        guideMgr.Inst.triggerGuide(guideId);
    }
    static cancelGuide(guideId: number) {
        guideMgr.Inst.cancleGuide(guideId);
    }
    static getGuide(id: number): IGuideConfig {
        if (!this._guideCfgMap.has(id)) {
            console.error(`未注册当前引导:${id}`);
            return null;
        }
        return this._guideCfgMap.get(id);
    }
    static registerGuide(cfg: IGuideConfig) {
        if (this._guideCfgMap.has(cfg.id))
            return;
        this._guideCfgMap.set(cfg.id, cfg);
    }
}

# guideUtil

引导工具类,用于自定义格式的节点动态搜索

/ : 父节点下的单层子节点
> : 父节点下递归搜索存在子节点
* : 获取多个同名的节点

static findNodes(locator: string): Node[] {
    // 使用正则表达示分隔名字
    let names = locator.split(/[//,>]/g);
    let index = 0;
    let segments = names.map(name => {
        let symbol = locator[index - 1] || '>';
        index += name.length + 1;
        return { symbol: symbol, name: name.trim() };
    });
    return this.locateNode(segments);
}
private static locateNode(segments: any[]): Node[] {
    let nodes = [];
    // 从 Canvas 查找,可配置
    let root = find('Canvas');
    let searchNodes: Node[] = [root];
    for (let i = 0; i < segments.length; i++) {
        let item = segments[i];
        let isMulti = item.name[0] == '*';
        let name = item.name.replace("*", "");
        let tempNode: Node[] = [];
        searchNodes.forEach(searchNode => {
            // 判断是否查找多节点
            if (isMulti) {
                if (item.symbol == "/") {
                    searchNode.children.forEach(child => {
                        if (child.name == name && tempNode.indexOf(child) < 0) {
                            tempNode.push(child);
                        }
                    })
                } else {
                    console.error("多节点查找只支持绝对路径");
                }
            } else {
                var node;
                switch (item.symbol) {
                    // 绝对路径查找
                    case '/':
                        node = searchNode.getChildByName(name);
                        break;
                    // 模糊搜索
                    case '>':
                        node = this.seekNodeByName(searchNode, name);
                        break;
                    default:
                        console.error(`未注册的解析类型:${item.symbol}`);
                        break;
                }
                if (node && tempNode.indexOf(node) < 0)
                    tempNode.push(node);
            }
        });
        if (i == segments.length - 1) {
            nodes = tempNode;
        } else {
            searchNodes = tempNode;
        }
    }
    return nodes;
}
// 通过节点名搜索节点对象
private static seekNodeByName(root: Node, name: string) {
    if (!root)
        return null;
    if (root.name == name)
        return root;
    let length = root.children.length;
    for (let i = 0; i < length; i++) {
        let child = root.children[i];
        let res = this.seekNodeByName(child, name);
        if (res != null)
            return res;
    }
    return null;
}

# 弱引导

单纯的手指在节点上呼吸显示,按理说一个页面不会同时存在多个手指弱引导吧?所以这里只设计了单一手指,要扩展自己做吧

interface IWeakGuide {
    guideId: number;
    stepId: number;
    args: IGuideWeakArgs;
    nodes: Node[];
    time: number;
    loopCnt: number;
    isShowing: boolean;
}
export class weakGuideMediator extends baseGuideMediator {
    private _curWeakGuides: IWeakGuide[] = [];
    protected onRun(args?: any) {
        // 一个 guide 允许多个 weakstep step 检测
        // 重复检测
        var guide = this.getGuide(this._guideId, this._stepId);
        if (guide) {
            guide.nodes = [];
            return;
        }
        if (!args.loop)
            args.loop = 1;
        guide = <IWeakGuide>{
            guideId: this._guideId,
            stepId: this._stepId,
            args: args,
            time: 0,
            loopCnt: 0,
            nodes: [],
            isShowing: false,
        }
        this._curWeakGuides.push(guide);
        //TODO 打开弱引导页面
        this.checkNode(guide);
    }
    protected onUpdate(dt: number) {
        for (let i = this._curWeakGuides.length - 1; i >= 0; i--) {
            let guide = this._curWeakGuides[i];
            if (!this.checkNode(guide))
                continue;
            if (guide.isShowing)
                continue;
            // 激活才有用吧?
            let activeNode = null;
            for (let j = 0; j < guide.nodes.length; j++) {
                if (guide.nodes[j].activeInHierarchy) {
                    activeNode = guide.nodes[j];
                    break;
                }
            }
            if (!activeNode) {
                if (!!guide.args.alwaysSearchNode)
                    guide.nodes = [];
                continue;
            }
            // 额外条件
            if (guide.args.condition && !guide.args.condition())
                continue;
            guide.time += dt;
            if (guide.args.waitTime <= guide.time) {
                guide.isShowing = true;
                //TODO 更新手指位置
            }
        }
    }
    protected onComplete() {
        if (this._curWeakGuides.length == 0) {
            //TODO 关闭弱引导 panel
        }
    }
    private checkNode(guide: IWeakGuide): boolean {
        if (!guide)
            return false;
        if (guide.nodes.length > 0)
            return true;
        guide.nodes = guideUtil.findNodes(guide.args.locator);
        if (guide.nodes.length > 0) {
            // 判断绑定在 node 还是全局上?
            if (guide.args.clickType == GuideWeakClickType.Global) {
                input.once(Input.EventType.TOUCH_END, () => this.onGuideNodeClick(guide.guideId, guide.stepId), this);
            } else {
                // 绑定点击 step 一次性监听
                guide.nodes.forEach(node => {
                    node.once(Input.EventType.TOUCH_END, () => this.onGuideNodeClick(guide.guideId, guide.stepId), this);
                })
            }
        }
        return guide.nodes.length > 0;
    }
    private onGuideNodeClick(guideId: number, stepId: number) {
        // 找到 guide
        var guide = this.getGuide(guideId, stepId);
        if (guide) {
            guide.loopCnt++;
            guide.nodes = [];
            guide.time = 0;
            guide.isShowing = false;
            if (guide.args.loop > 0 && guide.loopCnt >= guide.args.loop) {
                // 删了
                var idx = this._curWeakGuides.indexOf(guide);
                this._curWeakGuides.splice(idx, 1);
            }
        }
        // 下一步
        this.stepOver(guideId, stepId);
    }
    private getGuide(guideId: number, stepId: number): IWeakGuide {
        return this._curWeakGuides.find(v => v.guideId == guideId && v.stepId == stepId);
    }
}

# 强引导

与弱引导类似,只是多了个挖孔,封闭其他不可点击区域以及文本提示,由于业务需求,这边还多做了个手指移动的引导提示

interface IForceGuide {
    stepId: number;
    args: IGuideForceArgs;
    nodes: Node[];
}
export class forceGuideMediator extends baseGuideMediator {
    private _curForceGuide: IForceGuide;
    protected onRun(args?: any) {
        this._curForceGuide = <IForceGuide>{
            stepId: this._stepId,
            args: args,
            nodes: []
        }
        switch (args.type) {
            case GuideForceType.Click:
                this.executeClickForce();
                break;
            case GuideForceType.Move:
                this.executeMoveForce();
                break;
            default:
                console.error(`未定义当前类型的解析,请检查:${args.type}`);
                return;
        }
    }
    protected onUpdate(dt: number) {
        if (!this._curForceGuide)
            return;
        // 每帧 check 吧
        if (this._curForceGuide.args.type == GuideForceType.Move) {
            var moveArgs = this._curForceGuide.args as IGuideForceMoveArgs;
            if (this._curForceGuide.nodes.length < 2)
                return;
            if (moveArgs.stepCondition && !moveArgs.stepCondition(this._curForceGuide.nodes))
                return;
            // 松手时去刷新遮罩显示      
            this._curForceGuide.nodes.forEach(node => {
                var btn = node.getComponent(Button);
                if (btn) {
                    node.off(Button.EventType.CLICK, this.refreshMoveGuideShow, this);
                } else {
                    node.off(Input.EventType.TOUCH_END, this.refreshMoveGuideShow, this);
                }
            })
            this.stepComplete();
            this._curForceGuide = null;
        }
    }
    protected onComplete() {
        //TODO 关闭引导页面
    }
    private executeClickForce() {
        var args = this._curForceGuide.args as IGuideForceClickArgs;
        var locators = args.locators();
        if (this.setNodes(this._curForceGuide, locators) <= 0) {
            console.error(`未找到当前定义的Node点击节点,请检查:${locators}`)
            return;
        }
        // 点击完成
        var clickNode = this._curForceGuide.nodes[0];
        // 绑定点击 step 监听
        var btn = clickNode.getComponent(Button);
        if (btn) {
            clickNode.on(Button.EventType.CLICK, this.onClickForce, this);
        } else {
            clickNode.on(Input.EventType.TOUCH_END, this.onClickForce, this);
        }
        //TODO 刷新页面点击显示
    }
    private executeMoveForce() {
        var args = this._curForceGuide.args as IGuideForceMoveArgs;
        if (this.setNodes(this._curForceGuide, args.locators) < 2) {
            console.error(`未找到2个当前定义的Node移动节点,请检查:${args.locators}`);
            return;
        }
        // 松手时去刷新遮罩显示      
        this._curForceGuide.nodes.forEach(node => {
            var btn = node.getComponent(Button);
            if (btn) {
                node.on(Button.EventType.CLICK, this.refreshMoveGuideShow, this);
            } else {
                node.on(Input.EventType.TOUCH_END, this.refreshMoveGuideShow, this);
            }
        })
        this.refreshMoveGuideShow();
    }
    private setNodes(guide: IForceGuide, locators: string[]): number {
        guide.nodes = [];
        locators.forEach(locator => {
            var nodes = guideUtil.findNodes(locator);
            nodes.forEach(node => {
                if (node.active) {
                    guide.nodes.push(node);
                }
            })
        });
        return guide.nodes.length;
    }
    private stepComplete() {
        this.stepOver(this._guideId, this._curForceGuide.stepId);
        //TODO 关闭页面
    }
    private refreshMoveGuideShow() {
        var args = this._curForceGuide.args as IGuideForceMoveArgs;
        //TODO 刷新页面滑动显示
    }
    private onClickForce() {
        var args = this._curForceGuide.args as IGuideForceClickArgs;
        //TODO 刷新页面提示
        
        if (args.stepCondition && !args.stepCondition())
            return;
        var clickNode = this._curForceGuide.nodes[0];
        var btn = clickNode.getComponent(Button);
        if (btn) {
            clickNode.off(Button.EventType.CLICK, this.onClickForce, this);
        } else {
            clickNode.off(Input.EventType.TOUCH_END, this.onClickForce, this);
        }
        this.stepComplete();
    }
}
  • 挖孔做法
    理论上挖孔是可以通过 shader 或者修改 mesh 来实现的(Unity 是这样的),但由于时间紧任务重,又不熟的情况,这边就采用 Cocos 自带的反向遮罩来实现这一功能了
private _uiMask: Mask;
onLoad() {
    this._uiMask = this.getComponent(Mask);
}
// 挖孔
public digRect(rect: Rect) {
    this._uiMask.type = Mask.Type.GRAPHICS_RECT;
    this._uiMask.inverted = true;
    var trans = this._uiMask.getComponent(UITransform);
    var pos = trans.convertToNodeSpaceAR(v3(rect.x, rect.y));
    this._uiMask.node.setPosition(pos);
    trans.width = rect.width;
    trans.height = rect.height;
}

# 使用说明

先来看看效果

页面显示部分代码就不细写了,自己开项目看吧~

# 引导实例

  • 弱引导配置
const guide_1 = <IGuideConfig>{
    id: 1,
    name: "点击按钮",
    isRunGuide: true,
    isLocalSave: false,
    exeSequence: GuideExeSequence.Queue,
    steps: [
        {
            desc: "点击按钮1",
            command: {
                cmd: GuideCmd.WeakGuide,
                args: <IGuideWeakArgs>{
                    locator: "UI/Btn1",
                    clickType: GuideWeakClickType.Node,
                    waitTime: 3
                }
            },
        },
        {
            desc: "点击按钮2",
            command: {
                cmd: GuideCmd.WeakGuide,
                args: <IGuideWeakArgs>{
                    locator: "UI/Btn2",
                    clickType: GuideWeakClickType.Global,
                    waitTime: 0
                }
            },
        },
    ],
    onComplete: () => {
        // 执行完弱引导立刻执行强引导
        guideCenter.triggerGuide(2);
    }
}
guideCenter.registerGuide(guide_1);
  • 强引导配置
const guide_2 = <IGuideConfig>{
    id: 2,
    name: "点击按钮3 3次",
    isRunGuide: true,
    isLocalSave: false,
    exeSequence: GuideExeSequence.Queue,
    steps: [
        {
            desc: "点击按钮3",
            command: {
                cmd: GuideCmd.ForceGuide,
                args: <IGuideForceClickArgs>{
                    type: GuideForceType.Click,
                    locators: () => ["UI/Btn3"],
                    stepCondition: () => {
                        return guide2.clickCnt >= 3;
                    },
                    tips: <IGuideTips>{
                        getTips: () => {
                            return `点击按钮3 ${3 - guide2.clickCnt}`;
                        },
                        getWorldPos: nodePos => new Vec3(nodePos.x, nodePos.y + 200, 0)
                    },
                },
            },
            // 本地缓存了,只会跑一次,如果要再跑一次,请清除浏览器缓存
            isSaveServer: true
        }
    ],
}
guideCenter.registerGuide(guide_2);

# 入口 main

onLoad() {
    // 初始化引导管理器
    guideMgr.Inst;
}
start() {
    // 执行未完成的引导
    guideMgr.Inst.runGuides();
    // 直接触发引导 1
    guideCenter.triggerGuide(1);
}
update(deltaTime: number) {
    // 循环引导管理器
    guideMgr.Inst.update(deltaTime);
}

# 总结

嗯,只写了很简单的例子,手指移动的例子不想写,相信聪明的你一定能看懂并举一反三吧!!!总之,引导是非常多变哒,通常根据业务来实现功能哩,这里只介绍了大概的解决思路,具体功能还是看业务自己解决啦~(摆烂摆烂 x

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

汘帆 微信

微信

汘帆 支付宝

支付宝