# 前言
哈喽哇,新年快乐呀,作为一只不合格的鸽子,一有空就立刻来写文章啦,这次分享些什么好哩。想了想,最近又接手了新手引导的开发,那就来写一写这个挺恶心的模块的心得吧!!!
# 需求分析
又总所周知,新手引导之所以是一个很恶心的模块,其根本在于其模块过于业务化,基本一个游戏就要为其写一个引导流程,这就会导致在业务内嵌入大量的新手引导代码,这是违反开放封闭原则的,而且新手引导的改动频率还十分频繁,如果在业务模块内嵌入太多的代码的话,在频繁改动时你会发现,原先好好的业务写着写着变的面目全非,乱糟糟的了。为了解决嵌入过多的问题且最好做到模块复用性高,我就决定采用节点搜索 + 引导实例流程配置的方法进行解决。但如果是那种玩法很复杂,而且流程巨多的引导的话,这边还是建议新建一个场景用来专门实现吧。以下会根据 弱引导
以及 强引导
两种流程来进行新手引导的设计实现。
前排先放上项目地址哈: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