新公司,新项目,又做新手引导。不同之处在于这次的新手引导中更多的是剧情的展示,与剧情、任务等系统交互非常紧密,并且内嵌大量的timeline,而引导交互的内容相对较少。对于功能的实现,在参考之前的新手引导的基础上,也有了大量的改进。
简述
相对于上一次新手引导,主要有几方面的不同:
原来的项目框架中,对于UI界面,可通过view的名字直接同步获取到view对实例,因而处理逻辑更简单;新项目中没有这样的接口,只能取到Ctrl,进而异步取view的实例;
原来的项目中,通过view的实例取挖洞对象的代码,分散在各个view的代码中;新项目中功能逻辑和引导逻辑严格分开,所有诸如
GetInstructionObj
的代码都集中在同一个脚本中,以静态函数的方式出现,传入view实例,返回遮罩对象;原来的遮罩挖洞界面,挖洞区域对于点击事件是无法感知的,直接将点击事件穿透传递给下层的UI或地图上的对象;新项目中挖洞区域本身可以响应点击事件,在处理点击事件的回调中手动取调用挖洞对象的
OnClick
之类的方法;原来的引导系统,将触发、条件、动作等逻辑以及同步进度等都写在一个巨大的
InstructionMgr
脚本内,逻辑很混乱;新项目中这些逻辑分散到很多个不同的脚本中,每个脚本只处理各自对应的逻辑;新项目中使用了一套更完善的配表生成机制,配表中直接填写枚举值和字典,使配置更清晰,代码中解析配表也更简单;
框架
触发-条件-动作
引导系统基础的实现机制依然是”触发-条件-动作”,对应三个部分:
在事件中注册触发函数,这里的事件包括游戏加载完成,玩家得到xx道具,交付了xx任务,完成了id为xx的引导步等。触发通过向游戏逻辑模块的事件注册监听来实现;
在触发函数中,需要做条件判断,这里的条件包括玩家是否执行过某个动作(后边提到的marks),玩家是否达到某等级,是否拥有了某个道具等。条件判断的逻辑通常是在游戏逻辑模块中处理;
条件判断通过之后,会执行引导步的动作,动作执行的逻辑通常也是会调用游戏逻辑模块暴露出来的接口。
主要的脚本
InstructionModel
转化和重新存储配置数据,保存从服务器获取到的进度和状态信息。
配置表
配置表的格式如下,其中各param字段都是一个键值均为string的字典,各type字段是定义好的枚举值。
stepId | triggerType | triggerParam | conditionType | conditionParam | actionType | actionParam | keyStep |
---|---|---|---|---|---|---|---|
INT | ENUM | MAP | ENUM | MAP | ENUM | MAP | BOOL |
1001 | GameLoad | Always | PlayStory | storyId:100001 | true |
引导进度
正在进行的引导需要保存两个数据,lastFinishedStep
和currentStep
。
引导步开始和结束时会改变currentStep
,引导步结束时会修改lastFinishedStep
。currentStep
用于判断是否在引导过程中,当lastFinishedStep
发生变化时,会向服务器同步数据。
标记状态
字段marks
,用于记录支线/触发式的引导是否执行过。当marks
发生变化时,会向服务器同步。marks
需要与ActionType
中的SetMark
和ConditionType
中的NoMark
配合使用。初始状态marks
各位都是0,执行动作SetMark
后将对应位设置位1,在条件判断系统中会有条件类型NoNark
。
当登录游戏时,会请求lastFinishedStep
和marks
两个数据。其中lastFinishedStep
由前端向后端发送的完成引导步的消息和配表中的keyStep
共同决定,marks
完全决定于前端向后端发送的数据。
InstructionCtrl
负责引导系统总体的调度。当检测到新手引导开启并且玩家并未完成所有的新手引导时,则启用初始化,包括触发、条件、动作三部分的初始化。引导系统的总开关、调试功能的开关也都在InstructionCtrl
中。除此之外,InstructionCtrl
还负责与后端的进度同步,即消息的发送和接收解析;
InstructionTriggerCtrl
初始化时注册所有的触发函数,即向游戏内的各个事件添加监听,在触发的函数中根据触发参数做一些简单的判断,如果与触发参数一致则跳转到条件判断环节。
InstructionConditionCtrl
将各个系统暴露出来的接口进行加工,封装成一组能够返回一个布尔值的函数。如果判断通过则进入动作执行环节。如果conditionParam
中包含有字段onFailToStep
则会在条件判断失败时直接跳转到对应引导步。
InstructionActionCtrl
执行动作,直接调用各个系统暴露出来的接口,并在完成步骤时调用事件ActionEndEvent
。
在ActionEndEvent
中会同步引导进度,同时由于在InstructionTriggerCtrl
中为triggerType
为StepFinish
的引导步监听了此事件,所以可以推动引导的顺序进行。
同时为了实现跳过逻辑,在执行动作调用各个逻辑系统的接口时,各逻辑系统的接口需要返回一个handle,在调用接口之后,向ActionSkippedEvent
注册监听的函数,并将handle以闭包变量的形式封在函数内,以确保当玩家要跳过时可以调用。
InstructionInjector
这是向逻辑系统的脚本中注入引导逻辑的一个比较hacking的做法,有点类似与装饰器,可以替换掉原函数,也可以在原函数的调用之前、之后增加额外的函数调用。有点破坏整体框架,慎用。
1 | function InstructionInjector.PerformInjection(moduleName,funcName,willFunc,overrideFunc,didFunc) |
InstructionDebugger
引导调试器,唯一一个CS脚本,基于IMGUI绘制简单的界面,几个按钮分别绑定lua的几个函数,支持跳过当前、跳过全部、跳到指定步等功能。逻辑全部在lua中实现。
涉及到的UI
遮罩界面
引导系统中最常见的界面了,UI挖洞以达到让玩家点击暴露出来的UI组件的目的,依旧是使用MVC结构。
UIInstructionMaskModel
在打开遮罩界面之前,先将当前步的actionParam
保存下来到model
里,对应遮罩界面的actionParam
用于定义遮罩的对象、尺寸、位置和其它的一些信息,通常可以包括以下参数:
ui
需要挖洞的UI对象相对于view的完整路径,该对象上通常有Button、Toggle等响应点击的组件listView
涉及到的滚动列表组件,当需要获取滚动列表中的元素时,需包含此字段layoutRoot
涉及到在layout中取指定的一个对象时,需包含此字段index
通常配合listView
和layoutRoot
使用,获取其中的第几个对象xxxId
可以是道具id,兵种id等,根据给定的listView
和xxxId
在滚动列表中找到对应的itemsize
挖洞区域的尺寸,通常选区地图上的非UI对象时需要提供此字段dontPassClick
用于模拟一些假的点击,遮罩的UI接收到点击事件后,不再向下传递给真实的UI界面
UIInstructionMaskCtrl
操作界面的Open和Close方法,以打开和关闭界面。在打开遮罩界面后,并根据model中保存的actionParam
计算出一个框框以指引点击区域。主要分为两步:
获取点击对象,在脚本
UIInstructionMaskingUtils
中包含一组的方法,根据传入的view的luaInstance和actionParam
得到要获取的遮罩对象。遮罩对象即是指引玩家点击的对象,通常是一个button、或者image、toggle等或者是地图上一个带有collider和eventTrigger的GameObject;根据获取到的GameObject,计算出来一个rect,并使遮罩界面上的挖洞区域和rect对齐。
如果没有找到描述的GameObject,则将全屏遮黑,并监听OnViewOpen的回调,直到需要的界面view加载完成并准备好数据,再获取一次GameObject和rect。
根据UI或地图上的GameObject获取rect的方法如下:
1 | function UIInstructionMaskCtrl.UIObjToCanvasRect(uiObj) |
UIInstructionMaskView
即遮罩界面,在打开或找到目标时,显示UI挖洞区域,并与目标的rect对齐。
挖洞区域需要接受点击事件,回调函数OnClick
,在其中需要将点击事件传递给真正的点击对象,如果是UI就找到Selectable
调用点击方法,如果是地图对象,找到EventTrigger
并调用点击方法。
此外还需要处理一些其它的可选项,如箭头位置、箭头指向方向、文字内容、文字位置等,从actionParam
中获取配置。
UIInstructionMaskingUtils
提供一组静态方法,传入view的lua实例对象和actionParam
,返回一个GameObject:
1 | function Private.DefaultGetInstructionObj(viewLuaInstance,actionParams) |
不同的view,获取组件对象的方式差异较大,大多数情况下都需要对每个view单独写一些逻辑,不再展开详述。
引导员或剧情对话界面
全部由剧情系统实现,暴露出接口由引导系统调用。
全屏遮黑
只用一张白色的全屏Image,将淡入、淡出时间,持续时间,颜色等参数暴露出来,由配表控制即可。
其它直接调用的UI
通常如果ActionType
是打开一个界面OpenView
,需要在界面加载完成时调用OnActionEnd()
。如果UI界面涉及到的是一个完整的操作,如玩家起名、角色选择等,需要做成单独的ActionType
,打开界面时,向关闭界面的事件注册监听OnActionEnd
,关闭界面时调用才认为是这一步完成。
触发机制
在InstructionTriggerCtrl
初始化时,对配置中的引导步进行处理。遍历所有可能执行的引导步,根据其TriggerType
注册监听事件。玩家游戏过程中,操作触发了引导,会将触发的引导步的id作为参数传入注册的监听函数。举例说明:
在InstructionTriggerCtrl
内部的监听函数:
1 | function Private.CheckTriggerActionEnd(finishedStepId) |
初始化时注册监听,先把触发类型为StepFinish
的筛选出来:
1 | for k,v in pairs(InstructionModel.GetAllCfgSteps()) do |
为事件InstructionActionCtrl.ActionEndEvent
注册监听
1 | local InstructionActionCtrl = require("Game/Ctrl/InstructionActionCtrl") |
条件判断机制
介于触发系统和动作执行系统中间的一环,只有满足了条件才会去执行动作。同时支持配置将多个条件合并成一个条件,满足其一Any
或满足所有All
则认为是满足条件。默认类型为Always
,即直接返回true。
与触发系统类似,InstructionConditionCtrl
中也包含一组判断函数judge,当调用InstructionConditionCtrl.CheckAndPerformInstruction
的实现如下:
1 | function InstructionConditionCtrl.CheckAndPerformInstruction(stepInfo) |
例如InstructionConditionCtrl
中对于没有设置过mark的判断:
1 | function Private.JudgeFunc_NoMark(conditionParam) |
1 | function InstructionModel.CheckHasMark(mark) |
其中对于一些关系的判断,可以直接借助lua的load(string)
来完成。以下是判断玩家等级的判断函数,其中op通常是比较符号,如>
、<=
等:
1 | function Private.JudgeFunc_PlayerLevel(conditionParam) |
动作执行机制
动作系统初始化时同样根据ActionType注册函数,在执行动作时根据类别调用:
1 | function InstructionActionCtrl.PerformInstructionAction(stepInfo) |
动作执行直接调用各逻辑功能模块提供的接口即可。动作执行之前和之后,需要向后端同步。执行动作之后需要掉ActionEndEvent
事件:
1 | function Private.OnActionBegin(stepId) |
例如InstructionActionCtrl
中设置标记的执行动作:
1 | function Private.DoSetMark(stepId,actionParam) |
1 | function InstructionModel.SetMark(mark) |
对于不能立即完成的动作,如播放一个timeline,需要将完成动作的回调Private.OnActionEnd
传递给相关的系统,在timeline播放完成时调用。
除此之外动作类型还可以结合游戏框架里的一些基础系统,如等待几秒钟的时间(可以设置在等待时间期间禁掉触摸事件),或者预加载一个timeline的资源等。