游戏新手引导系统总结

新公司,新项目,又做新手引导。不同之处在于这次的新手引导中更多的是剧情的展示,与剧情、任务等系统交互非常紧密,并且内嵌大量的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
引导进度

正在进行的引导需要保存两个数据,lastFinishedStepcurrentStep

引导步开始和结束时会改变currentStep,引导步结束时会修改lastFinishedStepcurrentStep用于判断是否在引导过程中,当lastFinishedStep发生变化时,会向服务器同步数据。

标记状态

字段marks,用于记录支线/触发式的引导是否执行过。当marks发生变化时,会向服务器同步。marks需要与ActionType中的SetMarkConditionType中的NoMark配合使用。初始状态marks各位都是0,执行动作SetMark后将对应位设置位1,在条件判断系统中会有条件类型NoNark

当登录游戏时,会请求lastFinishedStepmarks两个数据。其中lastFinishedStep由前端向后端发送的完成引导步的消息和配表中的keyStep共同决定,marks完全决定于前端向后端发送的数据。

InstructionCtrl

负责引导系统总体的调度。当检测到新手引导开启并且玩家并未完成所有的新手引导时,则启用初始化,包括触发、条件、动作三部分的初始化。引导系统的总开关、调试功能的开关也都在InstructionCtrl中。除此之外,InstructionCtrl还负责与后端的进度同步,即消息的发送和接收解析;

InstructionTriggerCtrl

初始化时注册所有的触发函数,即向游戏内的各个事件添加监听,在触发的函数中根据触发参数做一些简单的判断,如果与触发参数一致则跳转到条件判断环节。

InstructionConditionCtrl

将各个系统暴露出来的接口进行加工,封装成一组能够返回一个布尔值的函数。如果判断通过则进入动作执行环节。如果conditionParam中包含有字段onFailToStep则会在条件判断失败时直接跳转到对应引导步。

InstructionActionCtrl

执行动作,直接调用各个系统暴露出来的接口,并在完成步骤时调用事件ActionEndEvent

ActionEndEvent中会同步引导进度,同时由于在InstructionTriggerCtrl中为triggerTypeStepFinish的引导步监听了此事件,所以可以推动引导的顺序进行。

同时为了实现跳过逻辑,在执行动作调用各个逻辑系统的接口时,各逻辑系统的接口需要返回一个handle,在调用接口之后,向ActionSkippedEvent注册监听的函数,并将handle以闭包变量的形式封在函数内,以确保当玩家要跳过时可以调用。

InstructionInjector

这是向逻辑系统的脚本中注入引导逻辑的一个比较hacking的做法,有点类似与装饰器,可以替换掉原函数,也可以在原函数的调用之前、之后增加额外的函数调用。有点破坏整体框架,慎用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function InstructionInjector.PerformInjection(moduleName,funcName,willFunc,overrideFunc,didFunc)
require(moduleName)
local module = package.loaded[moduleName]
if nil == module then return end
local originalFunc = module[funcName]
if originalFunc == nil or type(originalFunc) == "function" then
-- 如果不存在这个字段或者存在这个名字的函数
if willFunc or overrideFunc or didFunc then
module[funcName] = function(...)
local ret
if willFunc then willFunc(...) end
if overrideFunc then
ret = overrideFunc(...)
elseif originalFunc then
ret = originalFunc(...)
end
if didFunc then didFunc(...) end
return ret
end
end
end
end

InstructionDebugger

引导调试器,唯一一个CS脚本,基于IMGUI绘制简单的界面,几个按钮分别绑定lua的几个函数,支持跳过当前、跳过全部、跳到指定步等功能。逻辑全部在lua中实现。

涉及到的UI

遮罩界面

引导系统中最常见的界面了,UI挖洞以达到让玩家点击暴露出来的UI组件的目的,依旧是使用MVC结构。

UIInstructionMaskModel

在打开遮罩界面之前,先将当前步的actionParam保存下来到model里,对应遮罩界面的actionParam用于定义遮罩的对象、尺寸、位置和其它的一些信息,通常可以包括以下参数:

  • ui 需要挖洞的UI对象相对于view的完整路径,该对象上通常有Button、Toggle等响应点击的组件

  • listView 涉及到的滚动列表组件,当需要获取滚动列表中的元素时,需包含此字段

  • layoutRoot 涉及到在layout中取指定的一个对象时,需包含此字段

  • index 通常配合listViewlayoutRoot使用,获取其中的第几个对象

  • xxxId 可以是道具id,兵种id等,根据给定的listViewxxxId在滚动列表中找到对应的item

  • size 挖洞区域的尺寸,通常选区地图上的非UI对象时需要提供此字段

  • dontPassClick 用于模拟一些假的点击,遮罩的UI接收到点击事件后,不再向下传递给真实的UI界面

UIInstructionMaskCtrl

操作界面的Open和Close方法,以打开和关闭界面。在打开遮罩界面后,并根据model中保存的actionParam计算出一个框框以指引点击区域。主要分为两步:

  1. 获取点击对象,在脚本UIInstructionMaskingUtils中包含一组的方法,根据传入的view的luaInstance和actionParam得到要获取的遮罩对象。遮罩对象即是指引玩家点击的对象,通常是一个button、或者image、toggle等或者是地图上一个带有collider和eventTrigger的GameObject;

  2. 根据获取到的GameObject,计算出来一个rect,并使遮罩界面上的挖洞区域和rect对齐。

如果没有找到描述的GameObject,则将全屏遮黑,并监听OnViewOpen的回调,直到需要的界面view加载完成并准备好数据,再获取一次GameObject和rect。

根据UI或地图上的GameObject获取rect的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
function UIInstructionMaskCtrl.UIObjToCanvasRect(uiObj)
if not UIInstructionMaskCtrl.csRefs.uiCam then
-- UIInstructionMaskCtrl.csRefs.uiCam = ...
end
return UIInstructionMaskCtrl.ObjToCanvasRect(uiObj,UIInstructionMaskCtrl.csRefs.uiCam)
end

function UIInstructionMaskCtrl.MapObjToCanvasRect(mapObj)
if not UIInstructionMaskCtrl.csRefs.worldCam then
-- UIInstructionMaskCtrl.csRefs.worldCam = ...
end
return UIInstructionMaskCtrl.ObjToCanvasRect(mapObj,UIInstructionMaskCtrl.csRefs.worldCam)
end

function UIInstructionMaskCtrl.ObjToCanvasRect(obj,cam)
local worldPos = obj.transform.position
local viewPos = cam:WorldToViewportPoint(worldPos)

if not UIInstructionMaskCtrl.rootRect then
-- local rootCanvasObj = ..
local rootRect = rootCanvasObj:GetComponent(typeof(CS.UnityEngine.RectTransform)).rect
UIInstructionMaskCtrl.rootRect = {
width = rootRect.width,
height = rootRect.height
}
end

local canvasPos = {
x = viewPos.x * UIInstructionMaskCtrl.rootRect.width,
y = viewPos.y * UIInstructionMaskCtrl.rootRect.height
}

local rect
local objRect = obj:GetComponent(typeof(CS.UnityEngine.RectTransform))
if not IsNil(objRect) then
rect = objRect.rect
elseif nil ~= UIInstructionMaskModel.actionParam.size then
local size = UIInstructionMaskModel.actionParam.size
rect = {
x = -0.5 * size[1],
y = -0.5 * size[2],
width = size[1],
height = size[2],
}
else
rect = {
x = -50, y = -50,
width = 100, height = 100
}
end

return {
x = canvasPos.x + rect.x + rect.width/2,
y = canvasPos.y + rect.y + rect.height/2,
width = rect.width,
height = rect.height
}
end
UIInstructionMaskView

即遮罩界面,在打开或找到目标时,显示UI挖洞区域,并与目标的rect对齐。

挖洞区域需要接受点击事件,回调函数OnClick,在其中需要将点击事件传递给真正的点击对象,如果是UI就找到Selectable调用点击方法,如果是地图对象,找到EventTrigger并调用点击方法。

此外还需要处理一些其它的可选项,如箭头位置、箭头指向方向、文字内容、文字位置等,从actionParam中获取配置。

UIInstructionMaskingUtils

提供一组静态方法,传入view的lua实例对象和actionParam,返回一个GameObject:

1
2
3
function Private.DefaultGetInstructionObj(viewLuaInstance,actionParams)
-- ...
end

不同的view,获取组件对象的方式差异较大,大多数情况下都需要对每个view单独写一些逻辑,不再展开详述。

引导员或剧情对话界面

全部由剧情系统实现,暴露出接口由引导系统调用。

全屏遮黑

只用一张白色的全屏Image,将淡入、淡出时间,持续时间,颜色等参数暴露出来,由配表控制即可。

其它直接调用的UI

通常如果ActionType是打开一个界面OpenView,需要在界面加载完成时调用OnActionEnd()。如果UI界面涉及到的是一个完整的操作,如玩家起名、角色选择等,需要做成单独的ActionType,打开界面时,向关闭界面的事件注册监听OnActionEnd,关闭界面时调用才认为是这一步完成。

触发机制

InstructionTriggerCtrl初始化时,对配置中的引导步进行处理。遍历所有可能执行的引导步,根据其TriggerType注册监听事件。玩家游戏过程中,操作触发了引导,会将触发的引导步的id作为参数传入注册的监听函数。举例说明:

InstructionTriggerCtrl内部的监听函数:

1
2
3
4
5
6
7
8
function Private.CheckTriggerActionEnd(finishedStepId)
local triggeredStepId = Private.listenersForStepEnd[finishedStepId]
if not triggeredStepId then return end
local stepInfo = InstructionModel.GetCfg(triggeredStepId)
if stepInfo then
InstructionConditionCtrl.CheckAndPerformInstruction(stepInfo)
end
end

初始化时注册监听,先把触发类型为StepFinish的筛选出来:

1
2
3
4
5
6
7
for k,v in pairs(InstructionModel.GetAllCfgSteps()) do
-- ...
if v.trigger == TriggerType.StepFinish then
Private.listenersForStepEnd[tonumber(v.triggerParam.id)] = k
end
-- ...
end

为事件InstructionActionCtrl.ActionEndEvent注册监听

1
2
3
4
local InstructionActionCtrl = require("Game/Ctrl/InstructionActionCtrl")
InstructionActionCtrl.ActionEndEvent:AddListener(function(stepId)
Private.CheckTriggerActionEnd(stepId)
end)

条件判断机制

介于触发系统和动作执行系统中间的一环,只有满足了条件才会去执行动作。同时支持配置将多个条件合并成一个条件,满足其一Any或满足所有All则认为是满足条件。默认类型为Always,即直接返回true。

与触发系统类似,InstructionConditionCtrl中也包含一组判断函数judge,当调用InstructionConditionCtrl.CheckAndPerformInstruction的实现如下:

1
2
3
4
5
6
7
8
9
10
function InstructionConditionCtrl.CheckAndPerformInstruction(stepInfo)
local condition = stepInfo.condition
local judgeFunc = Private.judges[condition]
local conditionParam = InstructionUtils.ProcessParams(stepInfo.conditionParam)
if judgeFunc(conditionParam) then
InstructionActionCtrl.PerformInstructionAction(stepInfo)
elseif conditionParam.onFailStep then
InstructionActionCtrl.PerformInstructionAction(InstructionModel.GetCfg(conditionParam.onFailStep))
end
end

例如InstructionConditionCtrl中对于没有设置过mark的判断:

1
2
3
4
5
function Private.JudgeFunc_NoMark(conditionParam)
local mark = conditionParam.mark
local InstructionModel = require("Game/Model/InstructionModel")
return not InstructionModel.CheckHasMark(mark)
end
1
2
3
function InstructionModel.CheckHasMark(mark)
return Private.marks & (1 << mark) ~= 0
end

其中对于一些关系的判断,可以直接借助lua的load(string)来完成。以下是判断玩家等级的判断函数,其中op通常是比较符号,如><=等:

1
2
3
4
5
6
7
function Private.JudgeFunc_PlayerLevel(conditionParam)
local op = conditionParam.op
local level = conditionParam.level
local playerLevel = ...
local opStr = string.format("return %d %s %d",playerLevel,op,level)
return load(opStr)()
end

动作执行机制

动作系统初始化时同样根据ActionType注册函数,在执行动作时根据类别调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
function InstructionActionCtrl.PerformInstructionAction(stepInfo)
local InstructionCtrl = require("Game/Ctrl/InstructionCtrl")
InstructionCtrl.currentStepId = stepInfo.id
Private.OnActionBegin(stepInfo.id)
local handler = Private.handlers[stepInfo.action]
local actionParam = InstructionUtils.ProcessParams(stepInfo.actionParam)
if handler then
handler(stepInfo.id,actionParam)
else
Logger:Error("暂未实现 ActionType = " .. stepInfo.action)
Private.DoEmpty(stepInfo.id,actionParam)
end
end

动作执行直接调用各逻辑功能模块提供的接口即可。动作执行之前和之后,需要向后端同步。执行动作之后需要掉ActionEndEvent事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Private.OnActionBegin(stepId)
local InstructionModel = require("Game/Model/InstructionModel")
InstructionModel.currentStepId = stepId
local InstructionCtrl = require("Game/Ctrl/InstructionCtrl")
InstructionCtrl.SyncWithServer(stepId,false)
end

function Private.OnActionEnd(stepId)
local InstructionModel = require("Game/Model/InstructionModel")
if stepId == InstructionModel.currentStepId then
InstructionModel.currentStepId = -1
InstructionModel.lastFinishStepId = stepId
local InstructionCtrl = require("Game/Ctrl/InstructionCtrl")
InstructionCtrl.SyncWithServer(stepId,true)
InstructionActionCtrl.ActionEndEvent(stepId)
else
-- Logger:Error("错误!!! stepId = " .. stepId)
end
end

例如InstructionActionCtrl中设置标记的执行动作:

1
2
3
4
5
6
function Private.DoSetMark(stepId,actionParam)
local mark = actionParam.mark
local InstructionModel = require("Game/Model/InstructionModel")
InstructionModel.SetMark(mark)
Private.OnActionEnd(stepId)
end
1
2
3
function InstructionModel.SetMark(mark)
Private.marks = Private.marks | (1 << mark)
end

对于不能立即完成的动作,如播放一个timeline,需要将完成动作的回调Private.OnActionEnd传递给相关的系统,在timeline播放完成时调用。

除此之外动作类型还可以结合游戏框架里的一些基础系统,如等待几秒钟的时间(可以设置在等待时间期间禁掉触摸事件),或者预加载一个timeline的资源等。